@@ -356,7 +208,7 @@
ng-if="ctrl.formValues.ResourcePool"
auto-complete="false"
label-class="col-sm-3 col-lg-2"
- input-class="col-sm-8"
+ input-class="col-sm-9 col-lg-10"
namespace="ctrl.formValues.ResourcePool.Namespace.Name"
endpoint="ctrl.endpoint"
is-admin="ctrl.isAdmin"
@@ -373,29 +225,13 @@
-
-
-
+
diff --git a/app/kubernetes/views/applications/create/createApplicationController.js b/app/kubernetes/views/applications/create/createApplicationController.js
index 1f7b05ffd..f0a268d3f 100644
--- a/app/kubernetes/views/applications/create/createApplicationController.js
+++ b/app/kubernetes/views/applications/create/createApplicationController.js
@@ -151,6 +151,7 @@ class KubernetesCreateApplicationController {
this.getAppType = this.getAppType.bind(this);
this.showDataAccessPolicySection = this.showDataAccessPolicySection.bind(this);
this.refreshReactComponent = this.refreshReactComponent.bind(this);
+ this.onChangeNamespaceName = this.onChangeNamespaceName.bind(this);
this.$scope.$watch(
() => this.formValues,
@@ -168,6 +169,15 @@ class KubernetesCreateApplicationController {
this.$timeout(() => {
this.isTemporaryRefresh = false;
}, 10);
+ this.onChangeStackName = this.onChangeStackName.bind(this);
+ this.onChangeAppName = this.onChangeAppName.bind(this);
+ }
+ /* #endregion */
+
+ onChangeStackName(stackName) {
+ return this.$async(async () => {
+ this.formValues.StackName = stackName;
+ });
}
onChangePlacements(values) {
@@ -254,21 +264,16 @@ class KubernetesCreateApplicationController {
}
imageValidityIsValid() {
- return this.state.pullImageValidity || this.formValues.ImageModel.Registry.Type !== RegistryTypes.DOCKERHUB;
+ return this.state.pullImageValidity || (this.formValues.registryDetails && this.formValues.registryDetails.Registry.Type !== RegistryTypes.DOCKERHUB);
}
- onChangeName() {
- const existingApplication = _.find(this.applications, { Name: this.formValues.Name });
- this.state.alreadyExists = (this.state.isEdit && existingApplication && this.application.Id !== existingApplication.Id) || (!this.state.isEdit && existingApplication);
+ onChangeAppName(appName) {
+ return this.$async(async () => {
+ this.formValues.Name = appName;
+ });
}
/* #region AUTO SCALER UI MANAGEMENT */
- unselectAutoScaler() {
- if (this.formValues.DeploymentType === this.ApplicationDeploymentTypes.Global) {
- this.formValues.AutoScaler.isUsed = false;
- }
- }
-
onAutoScaleChange(values) {
return this.$async(async () => {
if (!this.formValues.AutoScaler.isUsed && values.isUsed) {
@@ -295,32 +300,6 @@ class KubernetesCreateApplicationController {
clearConfigMaps() {
this.formValues.ConfigMaps = [];
}
-
- onChangeConfigMapPath() {
- this.state.duplicates.configMapPaths.refs = [];
-
- const paths = _.reduce(
- this.formValues.ConfigMaps,
- (result, config) => {
- const uniqOverridenKeysPath = _.uniq(_.map(config.overridenKeys, 'path'));
- return _.concat(result, uniqOverridenKeysPath);
- },
- []
- );
-
- const duplicatePaths = KubernetesFormValidationHelper.getDuplicates(paths);
-
- _.forEach(this.formValues.ConfigMaps, (config, index) => {
- _.forEach(config.overridenKeys, (overridenKey, keyIndex) => {
- const findPath = _.find(duplicatePaths, (path) => path === overridenKey.path);
- if (findPath) {
- this.state.duplicates.configMapPaths.refs[index + '_' + keyIndex] = findPath;
- }
- });
- });
-
- this.state.duplicates.configMapPaths.hasRefs = Object.keys(this.state.duplicates.configMapPaths.refs).length > 0;
- }
/* #endregion */
/* #region SECRET UI MANAGEMENT */
@@ -421,7 +400,6 @@ class KubernetesCreateApplicationController {
/* #region STATE VALIDATION FUNCTIONS */
isValid() {
return (
- !this.state.alreadyExists &&
!this.state.duplicates.environmentVariables.hasRefs &&
!this.state.duplicates.persistedFolders.hasRefs &&
!this.state.duplicates.configMapPaths.hasRefs &&
@@ -434,10 +412,6 @@ class KubernetesCreateApplicationController {
return this.storageClasses && this.storageClasses.length > 0;
}
- hasMultipleStorageClassesAvailable() {
- return this.storageClasses && this.storageClasses.length > 1;
- }
-
resetDeploymentType() {
this.formValues.DeploymentType = this.ApplicationDeploymentTypes.Replicated;
}
@@ -740,6 +714,7 @@ class KubernetesCreateApplicationController {
return this.$async(async () => {
try {
this.applications = await this.KubernetesApplicationService.get(namespace);
+ this.applicationNames = _.map(this.applications, 'Name');
} catch (err) {
this.Notifications.error('Failure', err, 'Unable to retrieve applications');
}
@@ -796,7 +771,6 @@ class KubernetesCreateApplicationController {
this.refreshIngresses(namespace),
this.refreshVolumes(namespace),
]);
- this.onChangeName();
});
}
@@ -806,13 +780,13 @@ class KubernetesCreateApplicationController {
this.resetPersistedFolders();
}
- onResourcePoolSelectionChange() {
+ onChangeNamespaceName(namespaceName) {
return this.$async(async () => {
- const namespaceWithQuota = await this.KubernetesResourcePoolService.get(this.formValues.ResourcePool.Namespace.Name);
- const namespace = this.formValues.ResourcePool.Namespace.Name;
+ this.formValues.ResourcePool.Namespace.Name = namespaceName;
+ const namespaceWithQuota = await this.KubernetesResourcePoolService.get(namespaceName);
this.updateNamespaceLimits(namespaceWithQuota);
this.updateSliders(namespaceWithQuota);
- await this.refreshNamespaceData(namespace);
+ await this.refreshNamespaceData(namespaceName);
this.resetFormValues();
});
}
diff --git a/app/portainer/react/components/index.ts b/app/portainer/react/components/index.ts
index 09ed446bb..b617d827e 100644
--- a/app/portainer/react/components/index.ts
+++ b/app/portainer/react/components/index.ts
@@ -181,6 +181,7 @@ export const ngModule = angular
'isClearable',
'components',
'isLoading',
+ 'noOptionsMessage',
])
)
.component(
diff --git a/app/react-tools/withFormValidation.ts b/app/react-tools/withFormValidation.ts
index 71af58d35..b2ea4f903 100644
--- a/app/react-tools/withFormValidation.ts
+++ b/app/react-tools/withFormValidation.ts
@@ -1,6 +1,6 @@
import { IFormController, IComponentOptions, IModule } from 'angular';
import { FormikErrors } from 'formik';
-import { SchemaOf } from 'yup';
+import { SchemaOf, object } from 'yup';
import _ from 'lodash';
import { ComponentType } from 'react';
@@ -40,7 +40,8 @@ export function withFormValidation
(
Component: ComponentType>,
componentName: string,
propNames: PropNames[],
- schemaBuilder: (validationData?: TData) => SchemaOf
+ schemaBuilder: (validationData?: TData) => SchemaOf,
+ isPrimitive = false
) {
const reactComponentName = `react${_.upperFirst(componentName)}`;
@@ -54,7 +55,8 @@ export function withFormValidation(
createFormValidationComponent(
reactComponentName,
propNames,
- schemaBuilder
+ schemaBuilder,
+ isPrimitive
)
);
}
@@ -62,7 +64,8 @@ export function withFormValidation(
export function createFormValidationComponent(
componentName: string,
propNames: Array,
- schemaBuilder: (validationData?: TData) => SchemaOf
+ schemaBuilder: (validationData?: TData) => SchemaOf,
+ isPrimitive = false
): IComponentOptions {
const kebabName = _.kebabCase(componentName);
const propsWithErrors = [...propNames, 'errors', 'values'];
@@ -76,7 +79,7 @@ export function createFormValidationComponent(
on-change="($ctrl.handleChange)"
>${kebabName}>
`,
- controller: createFormValidatorController(schemaBuilder),
+ controller: createFormValidatorController(schemaBuilder, isPrimitive),
bindings: Object.fromEntries(
[...propsWithErrors, 'validationData', 'onChange'].map((p) => [p, '<'])
),
@@ -84,10 +87,11 @@ export function createFormValidationComponent(
}
function createFormValidatorController(
- schemaBuilder: (validationData?: TData) => SchemaOf
+ schemaBuilder: (validationData?: TData) => SchemaOf,
+ isPrimitive = false
) {
return class FormValidatorController {
- errors?: FormikErrors = {};
+ errors?: FormikErrors;
$async: (fn: () => Promise) => Promise;
@@ -118,12 +122,17 @@ function createFormValidatorController(
return this.$async(async () => {
this.form?.$setValidity('form', true, this.form);
- this.errors = await validateForm(
- () => schemaBuilder(this.validationData),
- value
- );
-
- if (this.errors && Object.keys(this.errors).length > 0) {
+ const schema = schemaBuilder(this.validationData);
+ this.errors = undefined;
+ const errors = await (isPrimitive
+ ? validateForm<{ value: TFormModel }>(
+ () => object({ value: schema }),
+ { value }
+ ).then((r) => r?.value)
+ : validateForm(() => schema, value));
+
+ if (errors && Object.keys(errors).length > 0) {
+ this.errors = errors as FormikErrors | undefined;
this.form?.$setValidity('form', false, this.form);
}
});
diff --git a/app/react/components/WebEditorForm.tsx b/app/react/components/WebEditorForm.tsx
index ab8880d05..140cd9788 100644
--- a/app/react/components/WebEditorForm.tsx
+++ b/app/react/components/WebEditorForm.tsx
@@ -15,7 +15,7 @@ import { buildConfirmButton } from './modals/utils';
const otherEditorConfig = {
tooltip: (
<>
- Ctrl+F - Start searching
+ CtrlF - Start searching
Ctrl+G - Find next
Ctrl+Shift+G - Find previous
Ctrl+Shift+F - Replace
@@ -29,7 +29,7 @@ const otherEditorConfig = {
searchCmdLabel: 'Ctrl+F for search',
} as const;
-const editorConfig = {
+export const editorConfig = {
mac: {
tooltip: (
<>
@@ -59,6 +59,7 @@ interface Props {
placeholder?: string;
yaml?: boolean;
readonly?: boolean;
+ titleContent?: React.ReactNode;
hideTitle?: boolean;
error?: string;
height?: string;
@@ -69,6 +70,7 @@ export function WebEditorForm({
onChange,
placeholder,
value,
+ titleContent = '',
hideTitle,
readonly,
yaml,
@@ -80,16 +82,11 @@ export function WebEditorForm({
{!hideTitle && (
-
- Web editor
-
- {editorConfig[BROWSER_OS_PLATFORM].searchCmdLabel}
-
-
-
-
+ <>
+
+ {titleContent ?? null}
+ >
)}
-
{children && (
{children}
@@ -116,6 +113,19 @@ export function WebEditorForm({
);
}
+function DefaultTitle({ id }: { id: string }) {
+ return (
+
+ Web editor
+
+ {editorConfig[BROWSER_OS_PLATFORM].searchCmdLabel}
+
+
+
+
+ );
+}
+
export function usePreventExit(
initialValue: string,
value: string,
diff --git a/app/react/components/form-components/FormControl/FormControl.tsx b/app/react/components/form-components/FormControl/FormControl.tsx
index 36e362b86..5f4c8727a 100644
--- a/app/react/components/form-components/FormControl/FormControl.tsx
+++ b/app/react/components/form-components/FormControl/FormControl.tsx
@@ -2,6 +2,7 @@ import { ComponentProps, PropsWithChildren, ReactNode } from 'react';
import clsx from 'clsx';
import { Tooltip } from '@@/Tip/Tooltip';
+import { InlineLoader } from '@@/InlineLoader';
import { FormError } from '../FormError';
@@ -17,6 +18,8 @@ export interface Props {
errors?: ReactNode;
required?: boolean;
className?: string;
+ isLoading?: boolean; // whether to show an inline loader, instead of the children
+ loadingText?: ReactNode; // text to show when isLoading is true
}
export function FormControl({
@@ -29,6 +32,8 @@ export function FormControl({
className,
required = false,
setTooltipHtmlMessage,
+ isLoading = false,
+ loadingText = 'Loading...',
}: PropsWithChildren
) {
return (
- {children}
+ {isLoading && {loadingText} }
+ {!isLoading && children}
{errors && {errors} }
diff --git a/app/react/components/form-components/PortainerSelect.tsx b/app/react/components/form-components/PortainerSelect.tsx
index 156cc33da..81105652b 100644
--- a/app/react/components/form-components/PortainerSelect.tsx
+++ b/app/react/components/form-components/PortainerSelect.tsx
@@ -28,6 +28,7 @@ interface SharedProps extends AutomationTestingProps {
isClearable?: boolean;
bindToBody?: boolean;
isLoading?: boolean;
+ noOptionsMessage?: () => string;
}
interface MultiProps extends SharedProps {
@@ -85,6 +86,7 @@ export function SingleSelect({
bindToBody,
components,
isLoading,
+ noOptionsMessage,
}: SingleProps) {
const selectedValue =
value || (typeof value === 'number' && value === 0)
@@ -108,6 +110,7 @@ export function SingleSelect({
menuPortalTarget={bindToBody ? document.body : undefined}
components={components}
isLoading={isLoading}
+ noOptionsMessage={noOptionsMessage}
/>
);
}
@@ -148,6 +151,7 @@ export function MultiSelect({
bindToBody,
components,
isLoading,
+ noOptionsMessage,
}: Omit, 'isMulti'>) {
const selectedOptions = findSelectedOptions(options, value);
return (
@@ -169,6 +173,7 @@ export function MultiSelect({
menuPortalTarget={bindToBody ? document.body : undefined}
components={components}
isLoading={isLoading}
+ noOptionsMessage={noOptionsMessage}
/>
);
}
diff --git a/app/react/kubernetes/DeployView/StackName/StackName.tsx b/app/react/kubernetes/DeployView/StackName/StackName.tsx
index e6679962d..3d4e88c40 100644
--- a/app/react/kubernetes/DeployView/StackName/StackName.tsx
+++ b/app/react/kubernetes/DeployView/StackName/StackName.tsx
@@ -1,15 +1,31 @@
+import { useMemo } from 'react';
+
+import { useCurrentUser } from '@/react/hooks/useUser';
+
import { InsightsBox } from '@@/InsightsBox';
import { Link } from '@@/Link';
import { TextTip } from '@@/Tip/TextTip';
import { Tooltip } from '@@/Tip/Tooltip';
+import { AutocompleteSelect } from '@@/form-components/AutocompleteSelect';
type Props = {
stackName: string;
setStackName: (name: string) => void;
- isAdmin?: boolean;
+ stacks?: string[];
+ inputClassName?: string;
};
-export function StackName({ stackName, setStackName, isAdmin = false }: Props) {
+export function StackName({
+ stackName,
+ setStackName,
+ stacks = [],
+ inputClassName,
+}: Props) {
+ const { isAdmin } = useCurrentUser();
+ const stackResults = useMemo(
+ () => stacks.filter((stack) => stack.includes(stackName ?? '')),
+ [stacks, stackName]
+ );
const tooltip = (
<>
You may specify a stack name to label resources that you want to group.
@@ -68,14 +84,16 @@ export function StackName({ stackName, setStackName, isAdmin = false }: Props) {
Stack
-
-
setStackName(e.target.value)}
- id="stack_name"
- placeholder="myStack"
+
+
({
+ value: result,
+ label: result,
+ }))}
+ value={stackName ?? ''}
+ onChange={setStackName}
+ placeholder="e.g. myStack"
+ inputId="stack_name"
/>
diff --git a/app/react/kubernetes/applications/components/EditYamlFormSection.tsx b/app/react/kubernetes/applications/components/EditYamlFormSection.tsx
new file mode 100644
index 000000000..e3800a5a4
--- /dev/null
+++ b/app/react/kubernetes/applications/components/EditYamlFormSection.tsx
@@ -0,0 +1,102 @@
+import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
+import { useEnvironmentDeploymentOptions } from '@/react/portainer/environments/queries/useEnvironment';
+import { useAuthorizations } from '@/react/hooks/useUser';
+
+import { WebEditorForm } from '@@/WebEditorForm';
+import { TextTip } from '@@/Tip/TextTip';
+
+type StackFileContent = string;
+
+type Props = {
+ values: StackFileContent;
+ onChange: (values: StackFileContent) => void;
+ isComposeFormat?: boolean;
+};
+
+export function EditYamlFormSection({
+ values,
+ onChange,
+ isComposeFormat,
+}: Props) {
+ // check if the user is allowed to edit the yaml
+ const environmentId = useEnvironmentId();
+ const { data: deploymentOptions } =
+ useEnvironmentDeploymentOptions(environmentId);
+ const roleHasAuth = useAuthorizations('K8sYAMLW');
+ const isAllowedToEdit = roleHasAuth && !deploymentOptions?.hideWebEditor;
+ const formId = 'kubernetes-deploy-editor';
+
+ return (
+
+ }
+ onChange={(values) => onChange(values)}
+ id={formId}
+ placeholder="Define or paste the content of your manifest file here"
+ yaml
+ />
+
+ );
+}
+
+function TitleContent({ isComposeFormat }: { isComposeFormat?: boolean }) {
+ return (
+ <>
+ {isComposeFormat && (
+
+
+ Portainer no longer supports{' '}
+
+ docker-compose
+ {' '}
+ format manifests for Kubernetes deployments, and we have removed the{' '}
+
+ Kompose
+ {' '}
+ conversion tool which enables this. The reason for this is because
+ Kompose now poses a security risk, since it has a number of Common
+ Vulnerabilities and Exposures (CVEs).
+
+
+ Unfortunately, while the Kompose project has a maintainer and is
+ part of the CNCF, it is not being actively maintained. Releases are
+ very infrequent and new pull requests to the project (including ones
+ we've submitted) are taking months to be merged, with new CVEs
+ arising in the meantime.
+
+
+ We advise installing your own instance of Kompose in a sandbox
+ environment, performing conversions of your Docker Compose files to
+ Kubernetes manifests and using those manifests to set up
+ applications.
+
+
+ )}
+ {!isComposeFormat && (
+
+
+ This feature allows you to deploy any kind of Kubernetes resource in
+ this environment (Deployment, Secret, ConfigMap...).
+
+
+ You can get more information about Kubernetes file format in the{' '}
+
+ official documentation
+
+ .
+
+
+ )}
+ >
+ );
+}
diff --git a/app/react/kubernetes/applications/components/NameFormSection/NameFormSection.tsx b/app/react/kubernetes/applications/components/NameFormSection/NameFormSection.tsx
new file mode 100644
index 000000000..99c7ba738
--- /dev/null
+++ b/app/react/kubernetes/applications/components/NameFormSection/NameFormSection.tsx
@@ -0,0 +1,38 @@
+import { FormikErrors } from 'formik';
+
+import { FormControl } from '@@/form-components/FormControl';
+import { Input } from '@@/form-components/Input';
+
+type Props = {
+ onChange: (value: string) => void;
+ values: string;
+ errors: FormikErrors;
+ isEdit: boolean;
+};
+
+export function NameFormSection({
+ onChange,
+ values: appName,
+ errors,
+ isEdit,
+}: Props) {
+ return (
+
+ onChange(e.target.value)}
+ autoFocus
+ placeholder="e.g. my-app"
+ disabled={isEdit}
+ id="application_name"
+ data-cy="k8sAppCreate-applicationName"
+ />
+
+ );
+}
diff --git a/app/react/kubernetes/applications/components/NameFormSection/index.ts b/app/react/kubernetes/applications/components/NameFormSection/index.ts
new file mode 100644
index 000000000..31c75800d
--- /dev/null
+++ b/app/react/kubernetes/applications/components/NameFormSection/index.ts
@@ -0,0 +1,2 @@
+export { NameFormSection } from './NameFormSection';
+export { appNameValidation } from './nameValidation';
diff --git a/app/react/kubernetes/applications/components/NameFormSection/nameValidation.ts b/app/react/kubernetes/applications/components/NameFormSection/nameValidation.ts
new file mode 100644
index 000000000..be9811ea4
--- /dev/null
+++ b/app/react/kubernetes/applications/components/NameFormSection/nameValidation.ts
@@ -0,0 +1,43 @@
+import { SchemaOf, string as yupString } from 'yup';
+
+type ValidationData = {
+ existingNames: string[];
+ isEdit: boolean;
+ originalName?: string;
+};
+
+export function appNameValidation(
+ validationData?: ValidationData
+): SchemaOf {
+ return yupString()
+ .required('This field is required.')
+ .test(
+ 'is-unique',
+ 'An application with the same name already exists inside the selected namespace.',
+ (appName) => {
+ if (!validationData || !appName) {
+ return true;
+ }
+ // if creating, check if the name is unique
+ if (!validationData.isEdit) {
+ return !validationData.existingNames.includes(appName);
+ }
+ // if editing, the original name will be in the list of existing names
+ // remove it before checking if the name is unique
+ const updatedExistingNames = validationData.existingNames.filter(
+ (name) => name !== validationData.originalName
+ );
+ return !updatedExistingNames.includes(appName);
+ }
+ )
+ .test(
+ 'is-valid',
+ "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').",
+ (appName) => {
+ if (!appName) {
+ return true;
+ }
+ return /^[a-z]([a-z0-9-]{0,61}[a-z0-9])?$/g.test(appName);
+ }
+ );
+}
diff --git a/app/react/kubernetes/applications/components/NamespaceSelector/NamespaceSelector.tsx b/app/react/kubernetes/applications/components/NamespaceSelector/NamespaceSelector.tsx
new file mode 100644
index 000000000..4e241e15a
--- /dev/null
+++ b/app/react/kubernetes/applications/components/NamespaceSelector/NamespaceSelector.tsx
@@ -0,0 +1,53 @@
+import { FormikErrors } from 'formik';
+
+import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
+import { useNamespacesQuery } from '@/react/kubernetes/namespaces/queries/useNamespacesQuery';
+
+import { FormControl } from '@@/form-components/FormControl';
+import { PortainerSelect } from '@@/form-components/PortainerSelect';
+
+type Props = {
+ onChange: (value: string) => void;
+ values: string;
+ errors: FormikErrors;
+ isEdit: boolean;
+};
+
+export function NamespaceSelector({
+ values: value,
+ onChange,
+ errors,
+ isEdit,
+}: Props) {
+ const environmentId = useEnvironmentId();
+ const { data: namespaces, ...namespacesQuery } =
+ useNamespacesQuery(environmentId);
+ const namespaceNames = Object.entries(namespaces ?? {})
+ .filter(([, ns]) => !ns.IsSystem)
+ .map(([nsName]) => ({
+ label: nsName,
+ value: nsName,
+ }));
+
+ return (
+
+ {namespaceNames.length > 0 && (
+ 'No namespaces found'}
+ placeholder="No namespaces found" // will only show when there are no options
+ inputId="namespace-selector"
+ data-cy="k8sAppCreate-nsSelect"
+ />
+ )}
+
+ );
+}
diff --git a/app/react/kubernetes/applications/components/NamespaceSelector/index.ts b/app/react/kubernetes/applications/components/NamespaceSelector/index.ts
new file mode 100644
index 000000000..574ce18fb
--- /dev/null
+++ b/app/react/kubernetes/applications/components/NamespaceSelector/index.ts
@@ -0,0 +1,2 @@
+export { NamespaceSelector } from './NamespaceSelector';
+export { namespaceSelectorValidation } from './namespaceSelectorValidation';
diff --git a/app/react/kubernetes/applications/components/NamespaceSelector/namespaceSelectorValidation.ts b/app/react/kubernetes/applications/components/NamespaceSelector/namespaceSelectorValidation.ts
new file mode 100644
index 000000000..222a50c1f
--- /dev/null
+++ b/app/react/kubernetes/applications/components/NamespaceSelector/namespaceSelectorValidation.ts
@@ -0,0 +1,38 @@
+import { SchemaOf, string } from 'yup';
+
+type ValidationData = {
+ hasQuota: boolean;
+ isResourceQuotaCapacityExceeded: boolean;
+ namespaceOptionCount: number;
+ isAdmin: boolean;
+};
+
+const emptyValue =
+ 'You do not have access to any namespace. Contact your administrator to get access to a namespace.';
+
+export function namespaceSelectorValidation(
+ validationData?: ValidationData
+): SchemaOf {
+ const {
+ hasQuota,
+ isResourceQuotaCapacityExceeded,
+ namespaceOptionCount,
+ isAdmin,
+ } = validationData || {};
+ return string()
+ .required(emptyValue)
+ .typeError(emptyValue)
+ .test(
+ 'resourceQuotaCapacityExceeded',
+ `This namespace has exhausted its resource capacity and you will not be able to deploy the application.${
+ isAdmin
+ ? ''
+ : ' Contact your administrator to expand the capacity of the namespace.'
+ }`,
+ () => {
+ const hasQuotaExceeded = hasQuota && isResourceQuotaCapacityExceeded;
+ return !hasQuotaExceeded;
+ }
+ )
+ .test('namespaceOptionCount', emptyValue, () => !!namespaceOptionCount);
+}
diff --git a/app/react/kubernetes/components/YAMLInspector.tsx b/app/react/kubernetes/components/YAMLInspector.tsx
index 18a8c0da4..3f8df9095 100644
--- a/app/react/kubernetes/components/YAMLInspector.tsx
+++ b/app/react/kubernetes/components/YAMLInspector.tsx
@@ -54,7 +54,7 @@ export function YAMLInspector({ identifier, data, hideMessage }: Props) {
);
}
-function cleanYamlUnwantedFields(yml: string) {
+export function cleanYamlUnwantedFields(yml: string) {
try {
const ymls = yml.split('---');
const cleanYmls = ymls.map((yml) => {
diff --git a/app/react/portainer/registries/queries/useRegistry.ts b/app/react/portainer/registries/queries/useRegistry.ts
index 779b8a85f..053888194 100644
--- a/app/react/portainer/registries/queries/useRegistry.ts
+++ b/app/react/portainer/registries/queries/useRegistry.ts
@@ -29,6 +29,6 @@ async function getRegistry(registryId: Registry['Id'], environmentId: number) {
});
return data;
} catch (err) {
- throw parseAxiosError(err as Error, 'XXXUnable to retrieve registry');
+ throw parseAxiosError(err as Error, 'Unable to retrieve registry');
}
}