mirror of
https://github.com/portainer/portainer.git
synced 2025-07-24 15:59:41 +02:00
refactor(kubernetes): move react codebase [EE-3349] (#7953)
This commit is contained in:
parent
2868da296a
commit
77c29ff87e
33 changed files with 18 additions and 21 deletions
|
@ -0,0 +1,84 @@
|
|||
import { ChangeEvent, ReactNode } from 'react';
|
||||
|
||||
import { Icon } from '@@/Icon';
|
||||
import { FormError } from '@@/form-components/FormError';
|
||||
|
||||
import { Annotation } from './types';
|
||||
|
||||
interface Props {
|
||||
annotations: Annotation[];
|
||||
handleAnnotationChange: (
|
||||
index: number,
|
||||
key: 'Key' | 'Value',
|
||||
val: string
|
||||
) => void;
|
||||
removeAnnotation: (index: number) => void;
|
||||
errors: Record<string, ReactNode>;
|
||||
placeholder: string[];
|
||||
}
|
||||
|
||||
export function Annotations({
|
||||
annotations,
|
||||
handleAnnotationChange,
|
||||
removeAnnotation,
|
||||
errors,
|
||||
placeholder,
|
||||
}: Props) {
|
||||
return (
|
||||
<>
|
||||
{annotations.map((annotation, i) => (
|
||||
<div className="row" key={annotation.ID}>
|
||||
<div className="form-group !pl-0 col-sm-4 !m-0">
|
||||
<div className="input-group input-group-sm">
|
||||
<span className="input-group-addon required">Key</span>
|
||||
<input
|
||||
name={`annotation_key_${i}`}
|
||||
type="text"
|
||||
className="form-control form-control-sm"
|
||||
placeholder={placeholder[0]}
|
||||
defaultValue={annotation.Key}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) =>
|
||||
handleAnnotationChange(i, 'Key', e.target.value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{errors[`annotations.key[${i}]`] && (
|
||||
<FormError className="mt-1 !mb-0">
|
||||
{errors[`annotations.key[${i}]`]}
|
||||
</FormError>
|
||||
)}
|
||||
</div>
|
||||
<div className="form-group !pl-0 col-sm-4 !m-0">
|
||||
<div className="input-group input-group-sm">
|
||||
<span className="input-group-addon required">Value</span>
|
||||
<input
|
||||
name={`annotation_value_${i}`}
|
||||
type="text"
|
||||
className="form-control form-control-sm"
|
||||
placeholder={placeholder[1]}
|
||||
defaultValue={annotation.Value}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) =>
|
||||
handleAnnotationChange(i, 'Value', e.target.value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{errors[`annotations.value[${i}]`] && (
|
||||
<FormError className="mt-1 !mb-0">
|
||||
{errors[`annotations.value[${i}]`]}
|
||||
</FormError>
|
||||
)}
|
||||
</div>
|
||||
<div className="col-sm-3 !pl-0 !m-0">
|
||||
<button
|
||||
className="btn btn-sm btn-dangerlight btn-only-icon !ml-0"
|
||||
type="button"
|
||||
onClick={() => removeAnnotation(i)}
|
||||
>
|
||||
<Icon icon="trash-2" size="md" feather />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,5 @@
|
|||
export interface Annotation {
|
||||
Key: string;
|
||||
Value: string;
|
||||
ID: string;
|
||||
}
|
|
@ -0,0 +1,742 @@
|
|||
import { useState, useEffect, useMemo, ReactNode } from 'react';
|
||||
import { useCurrentStateAndParams, useRouter } from '@uirouter/react';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { useEnvironmentId } from '@/portainer/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 } from '@/portainer/services/notifications';
|
||||
|
||||
import { Link } from '@@/Link';
|
||||
import { PageHeader } from '@@/PageHeader';
|
||||
import { Option } from '@@/form-components/Input/Select';
|
||||
import { Button } from '@@/buttons';
|
||||
|
||||
import { Ingress, IngressController } from '../types';
|
||||
import {
|
||||
useCreateIngress,
|
||||
useIngresses,
|
||||
useUpdateIngress,
|
||||
useIngressControllers,
|
||||
} from '../queries';
|
||||
|
||||
import { Annotation } from './Annotations/types';
|
||||
import { Rule, Path, Host } from './types';
|
||||
import { IngressForm } from './IngressForm';
|
||||
import {
|
||||
prepareTLS,
|
||||
preparePaths,
|
||||
prepareAnnotations,
|
||||
prepareRuleFromIngress,
|
||||
checkIfPathExistsWithHost,
|
||||
} from './utils';
|
||||
|
||||
export function CreateIngressView() {
|
||||
const environmentId = useEnvironmentId();
|
||||
const { params } = useCurrentStateAndParams();
|
||||
|
||||
const router = useRouter();
|
||||
const isEdit = !!params.namespace;
|
||||
|
||||
const [namespace, setNamespace] = useState<string>(params.namespace || '');
|
||||
const [ingressRule, setIngressRule] = useState<Rule>({} as Rule);
|
||||
|
||||
const [errors, setErrors] = useState<Record<string, ReactNode>>(
|
||||
{} as Record<string, string>
|
||||
);
|
||||
|
||||
const namespacesResults = useNamespaces(environmentId);
|
||||
|
||||
const servicesResults = useServices(environmentId, namespace);
|
||||
const configResults = useConfigurations(environmentId, namespace);
|
||||
const ingressesResults = useIngresses(
|
||||
environmentId,
|
||||
namespacesResults.data ? Object.keys(namespacesResults?.data || {}) : []
|
||||
);
|
||||
const ingressControllersResults = useIngressControllers(
|
||||
environmentId,
|
||||
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[],
|
||||
Ingress[],
|
||||
Record<string, number>,
|
||||
Record<string, string>
|
||||
] => {
|
||||
const ruleCounterByNamespace: Record<string, number> = {};
|
||||
const hostWithTLS: Record<string, string> = {};
|
||||
ingressesResults.data?.forEach((ingress) => {
|
||||
ingress.TLS?.forEach((tls) => {
|
||||
tls.Hosts.forEach((host) => {
|
||||
hostWithTLS[host] = tls.SecretName;
|
||||
});
|
||||
});
|
||||
});
|
||||
const ingressNames: string[] = [];
|
||||
ingressesResults.data?.forEach((ing) => {
|
||||
ruleCounterByNamespace[ing.Namespace] =
|
||||
ruleCounterByNamespace[ing.Namespace] || 0;
|
||||
const n = ing.Name.match(/^(.*)-(\d+)$/);
|
||||
if (n?.length === 3) {
|
||||
ruleCounterByNamespace[ing.Namespace] = Math.max(
|
||||
ruleCounterByNamespace[ing.Namespace],
|
||||
Number(n[2])
|
||||
);
|
||||
}
|
||||
if (ing.Namespace === namespace) {
|
||||
ingressNames.push(ing.Name);
|
||||
}
|
||||
});
|
||||
return [
|
||||
ingressNames || [],
|
||||
ingressesResults.data || [],
|
||||
ruleCounterByNamespace,
|
||||
hostWithTLS,
|
||||
];
|
||||
}, [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(
|
||||
() =>
|
||||
clusterIpServices?.map((service) => ({
|
||||
label: service.Name,
|
||||
value: service.Name,
|
||||
})),
|
||||
[clusterIpServices]
|
||||
);
|
||||
|
||||
const serviceOptions = [
|
||||
{ label: 'Select a service', value: '' },
|
||||
...(servicesOptions || []),
|
||||
];
|
||||
const servicePorts = useMemo(
|
||||
() =>
|
||||
clusterIpServices
|
||||
? Object.fromEntries(
|
||||
clusterIpServices?.map((service) => [
|
||||
service.Name,
|
||||
service.Ports.map((port) => ({
|
||||
label: String(port.Port),
|
||||
value: String(port.Port),
|
||||
})),
|
||||
])
|
||||
)
|
||||
: {},
|
||||
[clusterIpServices]
|
||||
);
|
||||
|
||||
const existingIngressClass = useMemo(
|
||||
() =>
|
||||
ingressControllersResults.data?.find(
|
||||
(i) =>
|
||||
i.ClassName === ingressRule.IngressClassName ||
|
||||
(i.Type === 'custom' && ingressRule.IngressClassName === '')
|
||||
),
|
||||
[ingressControllersResults.data, ingressRule.IngressClassName]
|
||||
);
|
||||
const ingressClassOptions: Option<string>[] = [
|
||||
{ label: 'Select an ingress class', value: '' },
|
||||
...(ingressControllersResults.data
|
||||
?.filter((cls) => cls.Availability)
|
||||
.map((cls) => ({
|
||||
label: cls.ClassName,
|
||||
value: cls.ClassName,
|
||||
})) || []),
|
||||
];
|
||||
|
||||
if (
|
||||
(!existingIngressClass ||
|
||||
(existingIngressClass && !existingIngressClass.Availability)) &&
|
||||
ingressRule.IngressClassName &&
|
||||
!ingressControllersResults.isLoading
|
||||
) {
|
||||
const optionLabel = !ingressRule.IngressType
|
||||
? `${ingressRule.IngressClassName} - NOT FOUND`
|
||||
: `${ingressRule.IngressClassName} - DISALLOWED`;
|
||||
ingressClassOptions.push({
|
||||
label: optionLabel,
|
||||
value: ingressRule.IngressClassName,
|
||||
});
|
||||
}
|
||||
|
||||
const matchedConfigs = configResults?.data?.filter(
|
||||
(config) =>
|
||||
config.SecretType === 'kubernetes.io/tls' &&
|
||||
config.Namespace === namespace
|
||||
);
|
||||
const tlsOptions: Option<string>[] = useMemo(
|
||||
() => [
|
||||
{ label: 'No TLS', value: '' },
|
||||
...(matchedConfigs?.map((config) => ({
|
||||
label: config.Name,
|
||||
value: config.Name,
|
||||
})) || []),
|
||||
],
|
||||
[matchedConfigs]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!!params.name &&
|
||||
ingressesResults.data &&
|
||||
!ingressRule.IngressName &&
|
||||
!ingressControllersResults.isLoading &&
|
||||
!ingressControllersResults.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(
|
||||
(c) =>
|
||||
c.ClassName === ing.ClassName ||
|
||||
(c.Type === 'custom' && !ing.ClassName)
|
||||
)?.Type;
|
||||
const r = prepareRuleFromIngress(ing, type);
|
||||
r.IngressType = type || r.IngressType;
|
||||
setIngressRule(r);
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
params.name,
|
||||
ingressesResults.data,
|
||||
ingressControllersResults.data,
|
||||
ingressRule.IngressName,
|
||||
params.namespace,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
// for each host, if the tls selection doesn't exist as an option, change it to the first option
|
||||
if (ingressRule?.Hosts?.length) {
|
||||
ingressRule.Hosts.forEach((host, hIndex) => {
|
||||
const secret = host.Secret || '';
|
||||
const tlsOptionVals = tlsOptions.map((o) => o.value);
|
||||
if (tlsOptions?.length && !tlsOptionVals?.includes(secret)) {
|
||||
handleTLSChange(hIndex, tlsOptionVals[0]);
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [tlsOptions, ingressRule.Hosts]);
|
||||
|
||||
useEffect(() => {
|
||||
// for each path in each host, if the service port doesn't exist as an option, change it to the first option
|
||||
if (ingressRule?.Hosts?.length) {
|
||||
ingressRule.Hosts.forEach((host, hIndex) => {
|
||||
host?.Paths?.forEach((path, pIndex) => {
|
||||
const serviceName = path.ServiceName;
|
||||
const currentServicePorts = servicePorts[serviceName]?.map(
|
||||
(p) => p.value
|
||||
);
|
||||
if (
|
||||
currentServicePorts?.length &&
|
||||
!currentServicePorts?.includes(String(path.ServicePort))
|
||||
) {
|
||||
handlePathChange(
|
||||
hIndex,
|
||||
pIndex,
|
||||
'ServicePort',
|
||||
currentServicePorts[0]
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [ingressRule, servicePorts]);
|
||||
|
||||
useEffect(() => {
|
||||
if (namespace.length > 0) {
|
||||
validate(
|
||||
ingressRule,
|
||||
ingressNames || [],
|
||||
servicesOptions || [],
|
||||
existingIngressClass
|
||||
);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
ingressRule,
|
||||
namespace,
|
||||
ingressNames,
|
||||
servicesOptions,
|
||||
existingIngressClass,
|
||||
]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title={isEdit ? 'Edit ingress' : 'Add ingress'}
|
||||
breadcrumbs={[
|
||||
{
|
||||
link: 'kubernetes.ingresses',
|
||||
label: 'Ingresses',
|
||||
},
|
||||
{
|
||||
label: isEdit ? 'Edit ingress' : 'Add ingress',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<div className="row ingress-rules">
|
||||
<div className="col-sm-12">
|
||||
<IngressForm
|
||||
environmentID={environmentId}
|
||||
isLoading={isLoading}
|
||||
isEdit={isEdit}
|
||||
rule={ingressRule}
|
||||
ingressClassOptions={ingressClassOptions}
|
||||
errors={errors}
|
||||
servicePorts={servicePorts}
|
||||
tlsOptions={tlsOptions}
|
||||
serviceOptions={serviceOptions}
|
||||
addNewIngressHost={addNewIngressHost}
|
||||
handleTLSChange={handleTLSChange}
|
||||
handleHostChange={handleHostChange}
|
||||
handleIngressChange={handleIngressChange}
|
||||
handlePathChange={handlePathChange}
|
||||
addNewIngressRoute={addNewIngressRoute}
|
||||
removeIngressHost={removeIngressHost}
|
||||
removeIngressRoute={removeIngressRoute}
|
||||
addNewAnnotation={addNewAnnotation}
|
||||
removeAnnotation={removeAnnotation}
|
||||
reloadTLSCerts={reloadTLSCerts}
|
||||
handleAnnotationChange={handleAnnotationChange}
|
||||
namespace={namespace}
|
||||
handleNamespaceChange={handleNamespaceChange}
|
||||
namespacesOptions={namespacesOptions}
|
||||
/>
|
||||
</div>
|
||||
{namespace && !isLoading && (
|
||||
<div className="col-sm-12">
|
||||
<Button
|
||||
onClick={() => handleCreateIngressRules()}
|
||||
disabled={Object.keys(errors).length > 0}
|
||||
>
|
||||
{isEdit ? 'Update' : 'Create'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
function validate(
|
||||
ingressRule: Rule,
|
||||
ingressNames: string[],
|
||||
serviceOptions: Option<string>[],
|
||||
existingIngressClass?: IngressController
|
||||
) {
|
||||
const errors: Record<string, ReactNode> = {};
|
||||
const rule = { ...ingressRule };
|
||||
|
||||
// User cannot edit the namespace and the ingress name
|
||||
if (!isEdit) {
|
||||
if (!rule.Namespace) {
|
||||
errors.namespace = 'Namespace is required';
|
||||
}
|
||||
|
||||
const nameRegex = /^[a-z]([a-z0-9-]{0,61}[a-z0-9])?$/;
|
||||
if (!rule.IngressName) {
|
||||
errors.ingressName = 'Ingress name is required';
|
||||
} else if (!nameRegex.test(rule.IngressName)) {
|
||||
errors.ingressName =
|
||||
"This field must consist of lower case alphanumeric characters or '-', contain at most 63 characters, start with an alphabetic character, and end with an alphanumeric character (e.g. 'my-name', or 'abc-123').";
|
||||
} else if (ingressNames.includes(rule.IngressName)) {
|
||||
errors.ingressName = 'Ingress name already exists';
|
||||
}
|
||||
|
||||
if (!rule.IngressClassName) {
|
||||
errors.className = 'Ingress class is required';
|
||||
}
|
||||
}
|
||||
|
||||
if (isEdit && !ingressRule.IngressClassName) {
|
||||
errors.className =
|
||||
'No ingress class is currently set for this ingress - use of the Portainer UI requires one to be set.';
|
||||
}
|
||||
|
||||
if (
|
||||
isEdit &&
|
||||
(!existingIngressClass ||
|
||||
(existingIngressClass && !existingIngressClass.Availability)) &&
|
||||
ingressRule.IngressClassName
|
||||
) {
|
||||
if (!rule.IngressType) {
|
||||
errors.className =
|
||||
'Currently set to an ingress class that cannot be found in the cluster - you must select a valid class.';
|
||||
} else {
|
||||
errors.className =
|
||||
'Currently set to an ingress class that you do not have access to - you must select a valid class.';
|
||||
}
|
||||
}
|
||||
|
||||
const duplicatedAnnotations: string[] = [];
|
||||
rule.Annotations?.forEach((a, i) => {
|
||||
if (!a.Key) {
|
||||
errors[`annotations.key[${i}]`] = 'Annotation key is required';
|
||||
} else if (duplicatedAnnotations.includes(a.Key)) {
|
||||
errors[`annotations.key[${i}]`] = 'Annotation cannot be duplicated';
|
||||
}
|
||||
if (!a.Value) {
|
||||
errors[`annotations.value[${i}]`] = 'Annotation value is required';
|
||||
}
|
||||
duplicatedAnnotations.push(a.Key);
|
||||
});
|
||||
|
||||
const duplicatedHosts: string[] = [];
|
||||
// Check if the paths are duplicates
|
||||
rule.Hosts?.forEach((host, hi) => {
|
||||
if (!host.NoHost) {
|
||||
if (!host.Host) {
|
||||
errors[`hosts[${hi}].host`] = 'Host is required';
|
||||
} else if (duplicatedHosts.includes(host.Host)) {
|
||||
errors[`hosts[${hi}].host`] = 'Host cannot be duplicated';
|
||||
}
|
||||
duplicatedHosts.push(host.Host);
|
||||
}
|
||||
|
||||
// Validate service
|
||||
host.Paths?.forEach((path, pi) => {
|
||||
if (!path.ServiceName) {
|
||||
errors[`hosts[${hi}].paths[${pi}].servicename`] =
|
||||
'Service name is required';
|
||||
}
|
||||
|
||||
if (
|
||||
isEdit &&
|
||||
path.ServiceName &&
|
||||
!serviceOptions.find((s) => s.value === path.ServiceName)
|
||||
) {
|
||||
errors[`hosts[${hi}].paths[${pi}].servicename`] = (
|
||||
<span>
|
||||
Currently set to {path.ServiceName}, which does not exist. You can
|
||||
create a service with this name for a particular deployment via{' '}
|
||||
<Link
|
||||
to="kubernetes.applications"
|
||||
params={{ id: environmentId }}
|
||||
className="text-primary"
|
||||
target="_blank"
|
||||
>
|
||||
Applications
|
||||
</Link>
|
||||
, and on returning here it will be picked up.
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (!path.ServicePort) {
|
||||
errors[`hosts[${hi}].paths[${pi}].serviceport`] =
|
||||
'Service port is required';
|
||||
}
|
||||
});
|
||||
// Validate paths
|
||||
const paths = host.Paths.map((path) => path.Route);
|
||||
paths.forEach((item, idx) => {
|
||||
if (!item) {
|
||||
errors[`hosts[${hi}].paths[${idx}].path`] = 'Path cannot be empty';
|
||||
} else if (paths.indexOf(item) !== idx) {
|
||||
errors[`hosts[${hi}].paths[${idx}].path`] =
|
||||
'Paths cannot be duplicated';
|
||||
} else {
|
||||
// Validate host and path combination globally
|
||||
const isExists = checkIfPathExistsWithHost(
|
||||
ingresses,
|
||||
host.Host,
|
||||
item,
|
||||
params.name
|
||||
);
|
||||
if (isExists) {
|
||||
errors[`hosts[${hi}].paths[${idx}].path`] =
|
||||
'Path is already in use with the same host';
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
setErrors(errors);
|
||||
if (Object.keys(errors).length > 0) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function handleNamespaceChange(ns: string) {
|
||||
setNamespace(ns);
|
||||
if (!isEdit) {
|
||||
addNewIngress(ns);
|
||||
}
|
||||
}
|
||||
|
||||
function handleIngressChange(key: string, val: string) {
|
||||
setIngressRule((prevRules) => {
|
||||
const rule = { ...prevRules, [key]: val };
|
||||
if (key === 'IngressClassName') {
|
||||
rule.IngressType = ingressControllersResults.data?.find(
|
||||
(c) => c.ClassName === val
|
||||
)?.Type;
|
||||
}
|
||||
return rule;
|
||||
});
|
||||
}
|
||||
|
||||
function handleTLSChange(hostIndex: number, tls: string) {
|
||||
setIngressRule((prevRules) => {
|
||||
const rule = { ...prevRules };
|
||||
rule.Hosts[hostIndex] = { ...rule.Hosts[hostIndex], Secret: tls };
|
||||
return rule;
|
||||
});
|
||||
}
|
||||
|
||||
function handleHostChange(hostIndex: number, val: string) {
|
||||
setIngressRule((prevRules) => {
|
||||
const rule = { ...prevRules };
|
||||
rule.Hosts[hostIndex] = { ...rule.Hosts[hostIndex], Host: val };
|
||||
rule.Hosts[hostIndex].Secret =
|
||||
hostWithTLS[val] || rule.Hosts[hostIndex].Secret;
|
||||
return rule;
|
||||
});
|
||||
}
|
||||
|
||||
function handlePathChange(
|
||||
hostIndex: number,
|
||||
pathIndex: number,
|
||||
key: 'Route' | 'PathType' | 'ServiceName' | 'ServicePort',
|
||||
val: string
|
||||
) {
|
||||
setIngressRule((prevRules) => {
|
||||
const rule = { ...prevRules };
|
||||
const h = { ...rule.Hosts[hostIndex] };
|
||||
h.Paths[pathIndex] = {
|
||||
...h.Paths[pathIndex],
|
||||
[key]: key === 'ServicePort' ? Number(val) : val,
|
||||
};
|
||||
|
||||
// set the first port of the service as the default port
|
||||
if (
|
||||
key === 'ServiceName' &&
|
||||
servicePorts[val] &&
|
||||
servicePorts[val].length > 0
|
||||
) {
|
||||
h.Paths[pathIndex].ServicePort = Number(servicePorts[val][0].value);
|
||||
}
|
||||
|
||||
rule.Hosts[hostIndex] = h;
|
||||
return rule;
|
||||
});
|
||||
}
|
||||
|
||||
function handleAnnotationChange(
|
||||
index: number,
|
||||
key: 'Key' | 'Value',
|
||||
val: string
|
||||
) {
|
||||
setIngressRule((prevRules) => {
|
||||
const rules = { ...prevRules };
|
||||
|
||||
rules.Annotations = rules.Annotations || [];
|
||||
rules.Annotations[index] = rules.Annotations[index] || {
|
||||
Key: '',
|
||||
Value: '',
|
||||
};
|
||||
rules.Annotations[index][key] = val;
|
||||
|
||||
return rules;
|
||||
});
|
||||
}
|
||||
|
||||
function addNewIngress(namespace: string) {
|
||||
const newKey = `${namespace}-ingress-${
|
||||
(ruleCounterByNamespace[namespace] || 0) + 1
|
||||
}`;
|
||||
const path: Path = {
|
||||
Key: uuidv4(),
|
||||
ServiceName: '',
|
||||
ServicePort: 0,
|
||||
Route: '',
|
||||
PathType: 'Prefix',
|
||||
};
|
||||
|
||||
const host: Host = {
|
||||
Host: '',
|
||||
Secret: '',
|
||||
Paths: [path],
|
||||
Key: uuidv4(),
|
||||
};
|
||||
|
||||
const rule: Rule = {
|
||||
Key: uuidv4(),
|
||||
Namespace: namespace,
|
||||
IngressName: newKey,
|
||||
IngressClassName: '',
|
||||
Hosts: [host],
|
||||
};
|
||||
|
||||
setIngressRule(rule);
|
||||
}
|
||||
|
||||
function addNewIngressHost(noHost = false) {
|
||||
const rule = { ...ingressRule };
|
||||
|
||||
const path: Path = {
|
||||
ServiceName: '',
|
||||
ServicePort: 0,
|
||||
Route: '',
|
||||
PathType: 'Prefix',
|
||||
Key: uuidv4(),
|
||||
};
|
||||
|
||||
const host: Host = {
|
||||
Host: '',
|
||||
Secret: '',
|
||||
Paths: [path],
|
||||
NoHost: noHost,
|
||||
Key: uuidv4(),
|
||||
};
|
||||
|
||||
rule.Hosts.push(host);
|
||||
setIngressRule(rule);
|
||||
}
|
||||
|
||||
function addNewIngressRoute(hostIndex: number) {
|
||||
const rule = { ...ingressRule };
|
||||
|
||||
const path: Path = {
|
||||
ServiceName: '',
|
||||
ServicePort: 0,
|
||||
Route: '',
|
||||
PathType: 'Prefix',
|
||||
Key: uuidv4(),
|
||||
};
|
||||
|
||||
rule.Hosts[hostIndex].Paths.push(path);
|
||||
setIngressRule(rule);
|
||||
}
|
||||
|
||||
function addNewAnnotation(type?: 'rewrite' | 'regex' | 'ingressClass') {
|
||||
const rule = { ...ingressRule };
|
||||
|
||||
const annotation: Annotation = {
|
||||
Key: '',
|
||||
Value: '',
|
||||
ID: uuidv4(),
|
||||
};
|
||||
switch (type) {
|
||||
case 'rewrite':
|
||||
annotation.Key = 'nginx.ingress.kubernetes.io/rewrite-target';
|
||||
annotation.Value = '/$1';
|
||||
break;
|
||||
case 'regex':
|
||||
annotation.Key = 'nginx.ingress.kubernetes.io/use-regex';
|
||||
annotation.Value = 'true';
|
||||
break;
|
||||
case 'ingressClass':
|
||||
annotation.Key = 'kubernetes.io/ingress.class';
|
||||
annotation.Value = '';
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
rule.Annotations = rule.Annotations || [];
|
||||
rule.Annotations?.push(annotation);
|
||||
setIngressRule(rule);
|
||||
}
|
||||
|
||||
function removeAnnotation(index: number) {
|
||||
const rule = { ...ingressRule };
|
||||
|
||||
if (index > -1) {
|
||||
rule.Annotations?.splice(index, 1);
|
||||
}
|
||||
|
||||
setIngressRule(rule);
|
||||
}
|
||||
|
||||
function removeIngressRoute(hostIndex: number, pathIndex: number) {
|
||||
const rule = { ...ingressRule, Hosts: [...ingressRule.Hosts] };
|
||||
if (hostIndex > -1 && pathIndex > -1) {
|
||||
rule.Hosts[hostIndex].Paths.splice(pathIndex, 1);
|
||||
}
|
||||
setIngressRule(rule);
|
||||
}
|
||||
|
||||
function removeIngressHost(hostIndex: number) {
|
||||
const rule = { ...ingressRule, Hosts: [...ingressRule.Hosts] };
|
||||
if (hostIndex > -1) {
|
||||
rule.Hosts.splice(hostIndex, 1);
|
||||
}
|
||||
setIngressRule(rule);
|
||||
}
|
||||
|
||||
function reloadTLSCerts() {
|
||||
configResults.refetch();
|
||||
}
|
||||
|
||||
function handleCreateIngressRules() {
|
||||
const rule = { ...ingressRule };
|
||||
|
||||
const classNameToSend =
|
||||
rule.IngressClassName === 'none' ? '' : rule.IngressClassName;
|
||||
|
||||
const ingress: Ingress = {
|
||||
Namespace: namespace,
|
||||
Name: rule.IngressName,
|
||||
ClassName: classNameToSend,
|
||||
Hosts: rule.Hosts.map((host) => host.Host),
|
||||
Paths: preparePaths(rule.IngressName, rule.Hosts),
|
||||
TLS: prepareTLS(rule.Hosts),
|
||||
Annotations: prepareAnnotations(rule.Annotations || []),
|
||||
};
|
||||
|
||||
if (isEdit) {
|
||||
updateIngressMutation.mutate(
|
||||
{ environmentId, ingress },
|
||||
{
|
||||
onSuccess: () => {
|
||||
notifySuccess('Success', 'Ingress updated successfully');
|
||||
router.stateService.go('kubernetes.ingresses');
|
||||
},
|
||||
}
|
||||
);
|
||||
} else {
|
||||
createIngressMutation.mutate(
|
||||
{ environmentId, ingress },
|
||||
{
|
||||
onSuccess: () => {
|
||||
notifySuccess('Success', 'Ingress created successfully');
|
||||
router.stateService.go('kubernetes.ingresses');
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
604
app/react/kubernetes/ingresses/CreateIngressView/IngressForm.tsx
Normal file
604
app/react/kubernetes/ingresses/CreateIngressView/IngressForm.tsx
Normal file
|
@ -0,0 +1,604 @@
|
|||
import { ChangeEvent, ReactNode } from 'react';
|
||||
import { Plus, RefreshCw, Trash2 } from 'react-feather';
|
||||
|
||||
import { Link } from '@@/Link';
|
||||
import { Icon } from '@@/Icon';
|
||||
import { Select, 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 { Annotations } from './Annotations';
|
||||
import { Rule, ServicePorts } from './types';
|
||||
|
||||
import '../style.css';
|
||||
|
||||
const PathTypes: Record<string, string[]> = {
|
||||
nginx: ['ImplementationSpecific', 'Prefix', 'Exact'],
|
||||
traefik: ['Prefix', 'Exact'],
|
||||
other: ['Prefix', 'Exact'],
|
||||
};
|
||||
const PlaceholderAnnotations: Record<string, string[]> = {
|
||||
nginx: ['e.g. nginx.ingress.kubernetes.io/rewrite-target', '/$1'],
|
||||
traefik: ['e.g. traefik.ingress.kubernetes.io/router.tls', 'true'],
|
||||
other: ['e.g. app.kubernetes.io/name', 'examplename'],
|
||||
};
|
||||
|
||||
interface Props {
|
||||
environmentID: number;
|
||||
rule: Rule;
|
||||
|
||||
errors: Record<string, ReactNode>;
|
||||
isLoading: boolean;
|
||||
isEdit: boolean;
|
||||
namespace: string;
|
||||
|
||||
servicePorts: ServicePorts;
|
||||
ingressClassOptions: Option<string>[];
|
||||
serviceOptions: Option<string>[];
|
||||
tlsOptions: Option<string>[];
|
||||
namespacesOptions: Option<string>[];
|
||||
|
||||
removeIngressRoute: (hostIndex: number, pathIndex: number) => void;
|
||||
removeIngressHost: (hostIndex: number) => void;
|
||||
removeAnnotation: (index: number) => void;
|
||||
|
||||
addNewIngressHost: (noHost?: boolean) => void;
|
||||
addNewIngressRoute: (hostIndex: number) => void;
|
||||
addNewAnnotation: (type?: 'rewrite' | 'regex' | 'ingressClass') => void;
|
||||
|
||||
handleNamespaceChange: (val: string) => void;
|
||||
handleHostChange: (hostIndex: number, val: string) => void;
|
||||
handleTLSChange: (hostIndex: number, tls: string) => void;
|
||||
handleIngressChange: (
|
||||
key: 'IngressName' | 'IngressClassName',
|
||||
value: string
|
||||
) => void;
|
||||
handleAnnotationChange: (
|
||||
index: number,
|
||||
key: 'Key' | 'Value',
|
||||
val: string
|
||||
) => void;
|
||||
handlePathChange: (
|
||||
hostIndex: number,
|
||||
pathIndex: number,
|
||||
key: 'Route' | 'PathType' | 'ServiceName' | 'ServicePort',
|
||||
val: string
|
||||
) => void;
|
||||
|
||||
reloadTLSCerts: () => void;
|
||||
}
|
||||
|
||||
export function IngressForm({
|
||||
environmentID,
|
||||
rule,
|
||||
isLoading,
|
||||
isEdit,
|
||||
servicePorts,
|
||||
tlsOptions,
|
||||
handleTLSChange,
|
||||
addNewIngressHost,
|
||||
serviceOptions,
|
||||
handleHostChange,
|
||||
handleIngressChange,
|
||||
handlePathChange,
|
||||
addNewIngressRoute,
|
||||
removeIngressRoute,
|
||||
removeIngressHost,
|
||||
addNewAnnotation,
|
||||
removeAnnotation,
|
||||
reloadTLSCerts,
|
||||
handleAnnotationChange,
|
||||
ingressClassOptions,
|
||||
errors,
|
||||
namespacesOptions,
|
||||
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;
|
||||
|
||||
return (
|
||||
<Widget>
|
||||
<WidgetTitle icon="svg-route" title="Ingress" />
|
||||
<WidgetBody key={rule.Key + rule.Namespace}>
|
||||
<div className="row">
|
||||
<div className="form-horizontal">
|
||||
<div className="form-group">
|
||||
<label
|
||||
className="control-label text-muted col-sm-3 col-lg-2 required"
|
||||
htmlFor="namespace"
|
||||
>
|
||||
Namespace
|
||||
</label>
|
||||
<div className="col-sm-4">
|
||||
{isEdit ? (
|
||||
namespace
|
||||
) : (
|
||||
<Select
|
||||
name="namespaces"
|
||||
options={namespacesOptions || []}
|
||||
onChange={(e) => handleNamespaceChange(e.target.value)}
|
||||
defaultValue={namespace}
|
||||
disabled={isEdit}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{namespace && (
|
||||
<div className="row">
|
||||
<div className="form-horizontal">
|
||||
<div className="form-group">
|
||||
<label
|
||||
className="control-label text-muted col-sm-3 col-lg-2 required"
|
||||
htmlFor="ingress_name"
|
||||
>
|
||||
Ingress name
|
||||
</label>
|
||||
<div className="col-sm-4">
|
||||
{isEdit ? (
|
||||
rule.IngressName
|
||||
) : (
|
||||
<input
|
||||
name="ingress_name"
|
||||
type="text"
|
||||
className="form-control"
|
||||
placeholder="Ingress name"
|
||||
defaultValue={rule.IngressName}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) =>
|
||||
handleIngressChange('IngressName', e.target.value)
|
||||
}
|
||||
disabled={isEdit}
|
||||
/>
|
||||
)}
|
||||
{errors.ingressName && !isEdit && (
|
||||
<FormError className="mt-1 error-inline">
|
||||
{errors.ingressName}
|
||||
</FormError>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group" key={ingressClassOptions.toString()}>
|
||||
<label
|
||||
className="control-label text-muted col-sm-3 col-lg-2 required"
|
||||
htmlFor="ingress_class"
|
||||
>
|
||||
Ingress class
|
||||
</label>
|
||||
<div className="col-sm-4">
|
||||
<Select
|
||||
name="ingress_class"
|
||||
className="form-control"
|
||||
placeholder="Ingress name"
|
||||
defaultValue={rule.IngressClassName}
|
||||
onChange={(e: ChangeEvent<HTMLSelectElement>) =>
|
||||
handleIngressChange('IngressClassName', e.target.value)
|
||||
}
|
||||
options={ingressClassOptions}
|
||||
/>
|
||||
{errors.className && (
|
||||
<FormError className="mt-1 error-inline">
|
||||
{errors.className}
|
||||
</FormError>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="col-sm-12 px-0 text-muted !mb-0">
|
||||
<div className="mb-2">Annotations</div>
|
||||
<p className="vertical-center text-muted small">
|
||||
<Icon icon="info" mode="primary" feather />
|
||||
<span>
|
||||
You can specify{' '}
|
||||
<a
|
||||
href="https://kubernetes.io/docs/concepts/overview/working-with-objects/annotations/"
|
||||
target="_black"
|
||||
>
|
||||
annotations
|
||||
</a>{' '}
|
||||
for the object. See further Kubernetes documentation on{' '}
|
||||
<a
|
||||
href="https://kubernetes.io/docs/reference/labels-annotations-taints/"
|
||||
target="_black"
|
||||
>
|
||||
well-known annotations
|
||||
</a>
|
||||
.
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{rule?.Annotations && (
|
||||
<Annotations
|
||||
placeholder={placeholderAnnotation}
|
||||
annotations={rule.Annotations}
|
||||
handleAnnotationChange={handleAnnotationChange}
|
||||
removeAnnotation={removeAnnotation}
|
||||
errors={errors}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="col-sm-12 p-0 anntation-actions">
|
||||
<Button
|
||||
className="btn btn-sm btn-light mb-2 !ml-0"
|
||||
onClick={() => addNewAnnotation()}
|
||||
icon={Plus}
|
||||
title="Use annotations to configure options for an ingress. Review Nginx or Traefik documentation to find the annotations supported by your choice of ingress type."
|
||||
>
|
||||
{' '}
|
||||
add annotation
|
||||
</Button>
|
||||
|
||||
{rule.IngressType === 'nginx' && (
|
||||
<>
|
||||
<Button
|
||||
className="btn btn-sm btn-light mb-2 ml-2"
|
||||
onClick={() => addNewAnnotation('rewrite')}
|
||||
icon={Plus}
|
||||
title="When the exposed URLs for your applications differ from the specified paths in the ingress, use the rewrite target annotation to denote the path to redirect to."
|
||||
data-cy="add-rewrite-annotation"
|
||||
>
|
||||
{' '}
|
||||
Add rewrite annotation
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
className="btn btn-sm btn-light mb-2 ml-2"
|
||||
onClick={() => addNewAnnotation('regex')}
|
||||
icon={Plus}
|
||||
title="When the exposed URLs for your applications differ from the specified paths in the ingress, use the rewrite target annotation to denote the path to redirect to."
|
||||
data-cy="add-regex-annotation"
|
||||
>
|
||||
Add regular expression annotation
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{rule.IngressType === 'custom' && (
|
||||
<Button
|
||||
className="btn btn-sm btn-light mb-2 ml-2"
|
||||
onClick={() => addNewAnnotation('ingressClass')}
|
||||
icon={Plus}
|
||||
data-cy="add-ingress-class-annotation"
|
||||
>
|
||||
Add kubernetes.io/ingress.class annotation
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="col-sm-12 px-0 text-muted">Rules</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{namespace &&
|
||||
rule?.Hosts?.map((host, hostIndex) => (
|
||||
<div className="row mb-5 rule bordered" key={host.Key}>
|
||||
<div className="col-sm-12">
|
||||
<div className="row mt-5 rule-actions">
|
||||
<div className="col-sm-3 p-0">
|
||||
{!host.NoHost ? 'Rule' : 'Fallback rule'}
|
||||
</div>
|
||||
<div className="col-sm-9 p-0 text-right">
|
||||
{!host.NoHost && (
|
||||
<Button
|
||||
className="btn btn-light btn-sm"
|
||||
onClick={() => reloadTLSCerts()}
|
||||
icon={RefreshCw}
|
||||
>
|
||||
Reload TLS secrets
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
className="btn btn-sm ml-2"
|
||||
color="dangerlight"
|
||||
type="button"
|
||||
data-cy={`k8sAppCreate-rmHostButton_${hostIndex}`}
|
||||
onClick={() => removeIngressHost(hostIndex)}
|
||||
disabled={rule.Hosts.length === 1}
|
||||
icon={Trash2}
|
||||
>
|
||||
Remove rule
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{!host.NoHost && (
|
||||
<div className="row">
|
||||
<div className="form-group !pl-0 col-sm-6 col-lg-4 !pr-2">
|
||||
<div className="input-group input-group-sm">
|
||||
<span className="input-group-addon required">
|
||||
Hostname
|
||||
</span>
|
||||
<input
|
||||
name={`ingress_host_${hostIndex}`}
|
||||
type="text"
|
||||
className="form-control form-control-sm"
|
||||
placeholder="e.g. example.com"
|
||||
defaultValue={host.Host}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) =>
|
||||
handleHostChange(hostIndex, e.target.value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{errors[`hosts[${hostIndex}].host`] && (
|
||||
<FormError className="mt-1 !mb-0">
|
||||
{errors[`hosts[${hostIndex}].host`]}
|
||||
</FormError>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="form-group !pr-0 col-sm-6 col-lg-4 !pl-2">
|
||||
<div className="input-group input-group-sm">
|
||||
<span className="input-group-addon">TLS secret</span>
|
||||
<Select
|
||||
key={tlsOptions.toString() + host.Secret}
|
||||
name={`ingress_tls_${hostIndex}`}
|
||||
options={tlsOptions}
|
||||
onChange={(e: ChangeEvent<HTMLSelectElement>) =>
|
||||
handleTLSChange(hostIndex, e.target.value)
|
||||
}
|
||||
defaultValue={host.Secret}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="vertical-center text-muted small whitespace-nowrap col-sm-12 !p-0">
|
||||
<Icon icon="info" mode="primary" size="md" feather />
|
||||
<span>
|
||||
Add a secret via{' '}
|
||||
<Link
|
||||
to="kubernetes.configurations"
|
||||
params={{ id: environmentID }}
|
||||
className="text-primary"
|
||||
target="_blank"
|
||||
>
|
||||
ConfigMaps & Secrets
|
||||
</Link>
|
||||
{', '}
|
||||
then select 'Reload TLS secrets' above to
|
||||
populate the dropdown with your changes.
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{host.NoHost && (
|
||||
<p className="vertical-center text-muted small whitespace-nowrap col-sm-12 !p-0">
|
||||
<Icon icon="info" mode="primary" size="md" feather />A
|
||||
fallback rule has no host specified. This rule only applies
|
||||
when an inbound request has a hostname that does not match
|
||||
with any of your other rules.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="row">
|
||||
<div className="col-sm-12 px-0 !mb-0 mt-2 text-muted">
|
||||
Paths
|
||||
</div>
|
||||
</div>
|
||||
{host.Paths.map((path, pathIndex) => (
|
||||
<div
|
||||
className="mt-5 !mb-5 row path"
|
||||
key={`path_${path.Key}}`}
|
||||
>
|
||||
<div className="form-group !pl-0 col-sm-3 col-xl-2 !m-0">
|
||||
<div className="input-group input-group-sm">
|
||||
<span className="input-group-addon required">
|
||||
Service
|
||||
</span>
|
||||
<Select
|
||||
key={serviceOptions.toString() + path.ServiceName}
|
||||
name={`ingress_service_${hostIndex}_${pathIndex}`}
|
||||
options={serviceOptions}
|
||||
onChange={(e: ChangeEvent<HTMLSelectElement>) =>
|
||||
handlePathChange(
|
||||
hostIndex,
|
||||
pathIndex,
|
||||
'ServiceName',
|
||||
e.target.value
|
||||
)
|
||||
}
|
||||
defaultValue={path.ServiceName}
|
||||
/>
|
||||
</div>
|
||||
{errors[
|
||||
`hosts[${hostIndex}].paths[${pathIndex}].servicename`
|
||||
] && (
|
||||
<FormError className="mt-1 !mb-0 error-inline">
|
||||
{
|
||||
errors[
|
||||
`hosts[${hostIndex}].paths[${pathIndex}].servicename`
|
||||
]
|
||||
}
|
||||
</FormError>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="form-group !pl-0 col-sm-2 col-xl-2 !m-0">
|
||||
{servicePorts && (
|
||||
<>
|
||||
<div className="input-group input-group-sm">
|
||||
<span className="input-group-addon required">
|
||||
Service port
|
||||
</span>
|
||||
<Select
|
||||
key={servicePorts.toString() + path.ServicePort}
|
||||
name={`ingress_servicePort_${hostIndex}_${pathIndex}`}
|
||||
options={
|
||||
path.ServiceName &&
|
||||
servicePorts[path.ServiceName]
|
||||
? servicePorts[path.ServiceName]
|
||||
: [
|
||||
{
|
||||
label: 'Select port',
|
||||
value: '',
|
||||
},
|
||||
]
|
||||
}
|
||||
onChange={(e: ChangeEvent<HTMLSelectElement>) =>
|
||||
handlePathChange(
|
||||
hostIndex,
|
||||
pathIndex,
|
||||
'ServicePort',
|
||||
e.target.value
|
||||
)
|
||||
}
|
||||
defaultValue={path.ServicePort}
|
||||
/>
|
||||
</div>
|
||||
{errors[
|
||||
`hosts[${hostIndex}].paths[${pathIndex}].serviceport`
|
||||
] && (
|
||||
<FormError className="mt-1 !mb-0">
|
||||
{
|
||||
errors[
|
||||
`hosts[${hostIndex}].paths[${pathIndex}].serviceport`
|
||||
]
|
||||
}
|
||||
</FormError>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="form-group !pl-0 col-sm-3 col-xl-2 !m-0">
|
||||
<div className="input-group input-group-sm">
|
||||
<span className="input-group-addon required">
|
||||
Path type
|
||||
</span>
|
||||
<Select
|
||||
key={servicePorts.toString() + path.PathType}
|
||||
name={`ingress_pathType_${hostIndex}_${pathIndex}`}
|
||||
options={
|
||||
pathTypes
|
||||
? pathTypes.map((type) => ({
|
||||
label: type,
|
||||
value: type,
|
||||
}))
|
||||
: []
|
||||
}
|
||||
onChange={(e: ChangeEvent<HTMLSelectElement>) =>
|
||||
handlePathChange(
|
||||
hostIndex,
|
||||
pathIndex,
|
||||
'PathType',
|
||||
e.target.value
|
||||
)
|
||||
}
|
||||
defaultValue={path.PathType}
|
||||
/>
|
||||
</div>
|
||||
{errors[
|
||||
`hosts[${hostIndex}].paths[${pathIndex}].pathType`
|
||||
] && (
|
||||
<FormError className="mt-1 !mb-0">
|
||||
{
|
||||
errors[
|
||||
`hosts[${hostIndex}].paths[${pathIndex}].pathType`
|
||||
]
|
||||
}
|
||||
</FormError>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="form-group !pl-0 col-sm-3 col-xl-3 !m-0">
|
||||
<div className="input-group input-group-sm">
|
||||
<span className="input-group-addon required">Path</span>
|
||||
<input
|
||||
className="form-control"
|
||||
name={`ingress_route_${hostIndex}-${pathIndex}`}
|
||||
placeholder="/example"
|
||||
data-pattern="/^(\/?[a-zA-Z0-9]+([a-zA-Z0-9-/_]*[a-zA-Z0-9])?|[a-zA-Z0-9]+)|(\/){1}$/"
|
||||
data-cy={`k8sAppCreate-route_${hostIndex}-${pathIndex}`}
|
||||
defaultValue={path.Route}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) =>
|
||||
handlePathChange(
|
||||
hostIndex,
|
||||
pathIndex,
|
||||
'Route',
|
||||
e.target.value
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{errors[
|
||||
`hosts[${hostIndex}].paths[${pathIndex}].path`
|
||||
] && (
|
||||
<FormError className="mt-1 !mb-0">
|
||||
{
|
||||
errors[
|
||||
`hosts[${hostIndex}].paths[${pathIndex}].path`
|
||||
]
|
||||
}
|
||||
</FormError>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="form-group !pl-0 col-sm-1 !m-0">
|
||||
<Button
|
||||
className="btn btn-sm btn-only-icon !ml-0 vertical-center"
|
||||
color="dangerlight"
|
||||
type="button"
|
||||
data-cy={`k8sAppCreate-rmPortButton_${hostIndex}-${pathIndex}`}
|
||||
onClick={() => removeIngressRoute(hostIndex, pathIndex)}
|
||||
disabled={host.Paths.length === 1}
|
||||
icon={Trash2}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="row mt-5">
|
||||
<Button
|
||||
className="btn btn-sm btn-light !ml-0"
|
||||
type="button"
|
||||
onClick={() => addNewIngressRoute(hostIndex)}
|
||||
icon={Plus}
|
||||
>
|
||||
Add path
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{namespace && (
|
||||
<div className="row p-0 rules-action">
|
||||
<div className="col-sm-12 p-0 vertical-center">
|
||||
<Button
|
||||
className="btn btn-sm btn-light !ml-0"
|
||||
type="button"
|
||||
onClick={() => addNewIngressHost()}
|
||||
icon={Plus}
|
||||
>
|
||||
Add new host
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
className="btn btn-sm btn-light ml-2"
|
||||
type="button"
|
||||
onClick={() => addNewIngressHost(true)}
|
||||
disabled={hasNoHostRule}
|
||||
icon={Plus}
|
||||
>
|
||||
Add fallback rule
|
||||
</Button>
|
||||
<Tooltip message="A fallback rule will be applied to all requests that do not match any of the defined hosts." />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</WidgetBody>
|
||||
</Widget>
|
||||
);
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export { CreateIngressView } from './CreateIngressView';
|
33
app/react/kubernetes/ingresses/CreateIngressView/types.ts
Normal file
33
app/react/kubernetes/ingresses/CreateIngressView/types.ts
Normal file
|
@ -0,0 +1,33 @@
|
|||
import { Option } from '@@/form-components/Input/Select';
|
||||
|
||||
import { Annotation } from './Annotations/types';
|
||||
|
||||
export interface Path {
|
||||
Key: string;
|
||||
Route: string;
|
||||
ServiceName: string;
|
||||
ServicePort: number;
|
||||
PathType?: string;
|
||||
}
|
||||
|
||||
export interface Host {
|
||||
Key: string;
|
||||
Host: string;
|
||||
Secret: string;
|
||||
Paths: Path[];
|
||||
NoHost?: boolean;
|
||||
}
|
||||
|
||||
export interface Rule {
|
||||
Key: string;
|
||||
IngressName: string;
|
||||
Namespace: string;
|
||||
IngressClassName: string;
|
||||
Hosts: Host[];
|
||||
Annotations?: Annotation[];
|
||||
IngressType?: string;
|
||||
}
|
||||
|
||||
export interface ServicePorts {
|
||||
[serviceName: string]: Option<string>[];
|
||||
}
|
136
app/react/kubernetes/ingresses/CreateIngressView/utils.ts
Normal file
136
app/react/kubernetes/ingresses/CreateIngressView/utils.ts
Normal file
|
@ -0,0 +1,136 @@
|
|||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { SupportedIngControllerTypes } from '@/react/kubernetes/cluster/ingressClass/types';
|
||||
|
||||
import { TLS, Ingress } from '../types';
|
||||
|
||||
import { Annotation } from './Annotations/types';
|
||||
import { Host, Rule } from './types';
|
||||
|
||||
const ignoreAnnotationsForEdit = [
|
||||
'kubectl.kubernetes.io/last-applied-configuration',
|
||||
];
|
||||
|
||||
export function prepareTLS(hosts: Host[]) {
|
||||
const tls: TLS[] = [];
|
||||
hosts.forEach((host) => {
|
||||
if (host.Secret && host.Host) {
|
||||
tls.push({
|
||||
Hosts: [host.Host],
|
||||
SecretName: host.Secret,
|
||||
});
|
||||
}
|
||||
});
|
||||
return tls;
|
||||
}
|
||||
|
||||
export function preparePaths(ingressName: string, hosts: Host[]) {
|
||||
return hosts.flatMap((host) =>
|
||||
host.Paths.map((p) => ({
|
||||
ServiceName: p.ServiceName,
|
||||
Host: host.Host,
|
||||
Path: p.Route,
|
||||
Port: p.ServicePort,
|
||||
PathType: p.PathType || 'Prefix',
|
||||
IngressName: ingressName,
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
export function prepareAnnotations(annotations: Annotation[]) {
|
||||
const result: Record<string, string> = {};
|
||||
annotations.forEach((a) => {
|
||||
result[a.Key] = a.Value;
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
function getSecretByHost(host: string, tls?: TLS[]) {
|
||||
let secret = '';
|
||||
if (tls) {
|
||||
tls.forEach((t) => {
|
||||
if (t.Hosts.indexOf(host) !== -1) {
|
||||
secret = t.SecretName;
|
||||
}
|
||||
});
|
||||
}
|
||||
return secret;
|
||||
}
|
||||
|
||||
export function prepareRuleHostsFromIngress(ing: Ingress) {
|
||||
const hosts = ing.Hosts?.map((host) => {
|
||||
const h: Host = {} as Host;
|
||||
h.Host = host;
|
||||
h.Secret = getSecretByHost(host, ing.TLS);
|
||||
h.Paths = [];
|
||||
ing.Paths?.forEach((path) => {
|
||||
if (path.Host === host) {
|
||||
h.Paths.push({
|
||||
Route: path.Path,
|
||||
ServiceName: path.ServiceName,
|
||||
ServicePort: path.Port,
|
||||
PathType: path.PathType,
|
||||
Key: Math.random().toString(),
|
||||
});
|
||||
}
|
||||
});
|
||||
if (!host) {
|
||||
h.NoHost = true;
|
||||
}
|
||||
h.Key = uuidv4();
|
||||
return h;
|
||||
});
|
||||
|
||||
return hosts;
|
||||
}
|
||||
|
||||
export function getAnnotationsForEdit(
|
||||
annotations: Record<string, string>
|
||||
): Annotation[] {
|
||||
const result: Annotation[] = [];
|
||||
Object.keys(annotations).forEach((k) => {
|
||||
if (ignoreAnnotationsForEdit.indexOf(k) === -1) {
|
||||
result.push({
|
||||
Key: k,
|
||||
Value: annotations[k],
|
||||
ID: uuidv4(),
|
||||
});
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
export function prepareRuleFromIngress(
|
||||
ing: Ingress,
|
||||
type?: SupportedIngControllerTypes
|
||||
): Rule {
|
||||
return {
|
||||
Key: uuidv4(),
|
||||
IngressName: ing.Name,
|
||||
Namespace: ing.Namespace,
|
||||
IngressClassName: type === 'custom' ? 'none' : ing.ClassName,
|
||||
Hosts: prepareRuleHostsFromIngress(ing) || [],
|
||||
Annotations: ing.Annotations ? getAnnotationsForEdit(ing.Annotations) : [],
|
||||
IngressType: ing.Type,
|
||||
};
|
||||
}
|
||||
|
||||
export function checkIfPathExistsWithHost(
|
||||
ingresses: Ingress[],
|
||||
host: string,
|
||||
path: string,
|
||||
ingressName?: string
|
||||
) {
|
||||
let exists = false;
|
||||
ingresses.forEach((ingress) => {
|
||||
if (ingressName && ingress.Name === ingressName) {
|
||||
return;
|
||||
}
|
||||
ingress.Paths?.forEach((p) => {
|
||||
if (p.Host === host && p.Path === path) {
|
||||
exists = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
return exists;
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue