refactor(app): migrate remaining form sections [EE-6231] (#10938)

pull/10939/head
Ali 2024-01-11 15:13:28 +13:00 committed by GitHub
parent 0b9cebc685
commit 4e7d1c7088
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 456 additions and 284 deletions

View File

@ -47,6 +47,15 @@ import {
autoScalingValidation, autoScalingValidation,
} from '@/react/kubernetes/applications/components/AutoScalingFormSection'; } from '@/react/kubernetes/applications/components/AutoScalingFormSection';
import { withControlledInput } from '@/react-tools/withControlledInput'; import { withControlledInput } from '@/react-tools/withControlledInput';
import {
NamespaceSelector,
namespaceSelectorValidation,
} from '@/react/kubernetes/applications/components/NamespaceSelector';
import { EditYamlFormSection } from '@/react/kubernetes/applications/components/EditYamlFormSection';
import {
NameFormSection,
appNameValidation,
} from '@/react/kubernetes/applications/components/NameFormSection';
import { EnvironmentVariablesFieldset } from '@@/form-components/EnvironmentVariablesFieldset'; import { EnvironmentVariablesFieldset } from '@@/form-components/EnvironmentVariablesFieldset';
@ -135,9 +144,17 @@ export const ngModule = angular
withUIRouter( withUIRouter(
withReactQuery(withCurrentUser(withControlledInput(StackName))) withReactQuery(withCurrentUser(withControlledInput(StackName)))
), ),
['setStackName', 'isAdmin', 'stackName'] ['setStackName', 'stackName', 'stacks', 'inputClassName']
) )
) )
.component(
'editYamlFormSection',
r2a(withUIRouter(withReactQuery(withCurrentUser(EditYamlFormSection))), [
'values',
'onChange',
'isComposeFormat',
])
)
.component( .component(
'applicationSummaryWidget', 'applicationSummaryWidget',
r2a( r2a(
@ -298,3 +315,21 @@ withFormValidation(
[], [],
placementValidation placementValidation
); );
withFormValidation(
ngModule,
withUIRouter(withCurrentUser(NamespaceSelector)),
'namespaceSelector',
['isEdit'],
namespaceSelectorValidation,
true
);
withFormValidation(
ngModule,
withUIRouter(withCurrentUser(withReactQuery(NameFormSection))),
'nameFormSection',
['isEdit'],
appNameValidation,
true
);

View File

@ -63,33 +63,12 @@
<div ng-if="ctrl.isExternalApplication()"> <div ng-if="ctrl.isExternalApplication()">
<div class="col-sm-12 form-section-title" ng-if="ctrl.state.appType === ctrl.KubernetesDeploymentTypes.APPLICATION_FORM"> Namespace </div> <div class="col-sm-12 form-section-title" ng-if="ctrl.state.appType === ctrl.KubernetesDeploymentTypes.APPLICATION_FORM"> Namespace </div>
<!-- #region NAMESPACE --> <!-- #region NAMESPACE -->
<div class="form-group" ng-if="ctrl.formValues.ResourcePool"> <namespace-selector
<label for="resource-pool-selector" class="col-sm-1 control-label text-left">Namespace</label> values="ctrl.formValues.ResourcePool.Namespace.Name"
<div class="col-sm-11"> on-change="(ctrl.onChangeNamespaceName)"
<select validation-data="{hasQuota: ctrl.state.resourcePoolHasQuota, isResourceQuotaCapacityExceeded: ctrl.resourceQuotaCapacityExceeded(), namespaceOptionCount: ctrl.resourcePools.length, isAdmin: ctrl.isAdmin}"
class="form-control" is-edit="ctrl.state.isEdit"
id="resource-pool-selector" ></namespace-selector>
ng-model="ctrl.formValues.ResourcePool"
ng-options="resourcePool.Namespace.Name for resourcePool in ctrl.resourcePools"
ng-change="ctrl.onResourcePoolSelectionChange()"
ng-disabled="ctrl.state.isEdit"
data-cy="k8sAppCreate-nsSelect"
></select>
</div>
</div>
<div class="form-group" ng-if="ctrl.state.resourcePoolHasQuota && ctrl.resourceQuotaCapacityExceeded() && ctrl.formValues.ResourcePool">
<div class="col-sm-12 small text-danger">
<pr-icon icon="'alert-circle'" mode="'danger'"></pr-icon>
This namespace has exhausted its resource capacity and you will not be able to deploy the application. Contact your administrator to expand the capacity of the
namespace.
</div>
</div>
<div class="form-group" ng-if="!ctrl.formValues.ResourcePool">
<div class="col-sm-12 small text-muted">
<pr-icon icon="'alert-circle'" mode="'warning'"></pr-icon>
You do not have access to any namespace. Contact your administrator to get access to a namespace.
</div>
</div>
<!-- kubernetes services options --> <!-- kubernetes services options -->
<div ng-if="ctrl.formValues.ResourcePool"> <div ng-if="ctrl.formValues.ResourcePool">
<kube-services-form <kube-services-form
@ -177,81 +156,22 @@
type="'application'" type="'application'"
></git-form-info-panel> ></git-form-info-panel>
<!-- #region NAMESPACE --> <!-- #region NAMESPACE -->
<div class="form-group" ng-if="ctrl.formValues.ResourcePool"> <namespace-selector
<label for="resource-pool-selector" class="col-sm-3 col-lg-2 control-label text-left">Namespace</label> values="ctrl.formValues.ResourcePool.Namespace.Name"
<div class="col-sm-8"> on-change="(ctrl.onChangeNamespaceName)"
<select validation-data="{hasQuota: ctrl.state.resourcePoolHasQuota, isResourceQuotaCapacityExceeded: ctrl.resourceQuotaCapacityExceeded(), namespaceOptionCount: ctrl.resourcePools.length, isAdmin: ctrl.isAdmin}"
class="form-control" is-edit="ctrl.state.isEdit"
id="resource-pool-selector" ></namespace-selector>
ng-model="ctrl.formValues.ResourcePool"
ng-options="resourcePool.Namespace.Name for resourcePool in ctrl.resourcePools"
ng-change="ctrl.onResourcePoolSelectionChange()"
ng-disabled="ctrl.state.isEdit"
data-cy="k8sAppCreate-nsSelect"
></select>
</div>
</div>
<div class="form-group" ng-if="ctrl.state.resourcePoolHasQuota && ctrl.resourceQuotaCapacityExceeded() && ctrl.formValues.ResourcePool">
<div class="col-sm-12 small text-danger">
<pr-icon icon="'alert-triangle'"></pr-icon>
This namespace has exhausted its resource capacity and you will not be able to deploy the application. Contact your administrator to expand the capacity of the
namespace.
</div>
</div>
<div class="form-group" ng-if="!ctrl.formValues.ResourcePool">
<div class="col-sm-12 small text-warning">
<pr-icon icon="'alert-triangle'"></pr-icon>
You do not have access to any namespace. Contact your administrator to get access to a namespace.
</div>
</div>
<!-- #endregion --> <!-- #endregion -->
<!-- #region STACK --> <!-- #region STACK -->
<div class="form-group" ng-if="!ctrl.deploymentOptions.hideStacksFunctionality && ctrl.state.appType !== ctrl.KubernetesDeploymentTypes.APPLICATION_FORM"> <kube-stack-name
<div class="col-sm-12 small text-muted vertical-center"> ng-if="!ctrl.deploymentOptions.hideStacksFunctionality && ctrl.state.appType !== ctrl.KubernetesDeploymentTypes.APPLICATION_FORM"
<pr-icon icon="'info'" mode="'primary'"></pr-icon> stack-name="ctrl.formValues.StackName"
Portainer can automatically bundle multiple applications inside a stack. Enter a name of a new stack or select an existing stack in the list. Leave empty to use set-stack-name="(ctrl.onChangeStackName)"
the application name. stacks="ctrl.stacks"
</div> input-class-name="'col-lg-10 col-sm-9'"
</div> ></kube-stack-name>
<div class="form-group" ng-if="!ctrl.deploymentOptions.hideStacksFunctionality && ctrl.state.appType !== ctrl.KubernetesDeploymentTypes.APPLICATION_FORM">
<label for="stack_name" class="col-sm-3 col-lg-2 control-label text-left">
Stack
<portainer-tooltip
ng-if="!ctrl.isAdmin"
message="'The stack field below was previously labelled \'Name\' but, in
fact, it\'s always been the stack name (hence the relabelling).'"
class-name="'[&>span]:!text-left'"
set-html-message="true"
>
</portainer-tooltip>
<portainer-tooltip
ng-if="ctrl.isAdmin"
message="'The stack field below was previously labelled \'Name\' but, in
fact, it\'s always been the stack name (hence the relabelling).<br/>
Kubernetes Stacks functionality can be turned off entirely via
<a href=\'/#!/settings\' target=\'_blank\'>
Kubernetes Settings
</a>.'"
class-name="'[&>span]:!text-left'"
set-html-message="true"
>
</portainer-tooltip>
</label>
<div class="col-sm-8">
<input
type="text"
class="form-control"
placeholder="myStack"
ng-model="ctrl.formValues.StackName"
name="stack_name"
uib-typeahead="stack for stack in ctrl.stacks | filter:$viewValue | limitTo:7"
typeahead-min-length="0"
data-cy="k8sAppCreate-stackName"
/>
</div>
</div>
<!-- #endregion --> <!-- #endregion -->
<!-- #region Git repository --> <!-- #region Git repository -->
@ -263,89 +183,21 @@
<!-- #endregion --> <!-- #endregion -->
<!-- #region web editor --> <!-- #region web editor -->
<web-editor-form <edit-yaml-form-section
read-only="ctrl.stack.IsComposeFormat"
ng-if="ctrl.state.appType === ctrl.KubernetesDeploymentTypes.CONTENT" ng-if="ctrl.state.appType === ctrl.KubernetesDeploymentTypes.CONTENT"
value="ctrl.stackFileContent" values="ctrl.stackFileContent"
yml="true"
identifier="kubernetes-deploy-editor"
placeholder="Define or paste the content of your manifest file here"
on-change="(ctrl.onChangeFileContent)" on-change="(ctrl.onChangeFileContent)"
> is-compose-format="ctrl.stack.IsComposeFormat"
<editor-description> ></edit-yaml-form-section>
<div class="flex gap-1" ng-show="ctrl.stack.IsComposeFormat">
<pr-icon icon="'alert-circle'" mode="'warning'" class-name="'!mt-1'"></pr-icon>
<div>
<p>
Portainer no longer supports <a href="https://docs.docker.com/compose/compose-file/" target="_blank">docker-compose</a> format manifests for Kubernetes
deployments, and we have removed the <a href="https://kompose.io/" target="_blank">Kompose</a>
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).
</p>
<p>
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.
</p>
<p>
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.
</p>
</div>
</div>
<span ng-show="!ctrl.stack.IsComposeFormat">
<p class="vertical-center">
<pr-icon icon="'info'" mode="'primary'"></pr-icon>
This feature allows you to deploy any kind of Kubernetes resource in this environment (Deployment, Secret, ConfigMap...).
</p>
<p>
You can get more information about Kubernetes file format in the
<a href="https://kubernetes.io/docs/concepts/overview/working-with-objects/kubernetes-objects/" target="_blank">official documentation</a>.
</p>
</span>
</editor-description>
</web-editor-form>
<!-- #endregion --> <!-- #endregion -->
<div ng-if="ctrl.state.appType === ctrl.KubernetesDeploymentTypes.APPLICATION_FORM"> <div ng-if="ctrl.state.appType === ctrl.KubernetesDeploymentTypes.APPLICATION_FORM">
<!-- #region NAME FIELD --> <!-- #region NAME FIELD -->
<div class="form-group"> <name-form-section
<label for="application_name" class="col-sm-3 col-lg-2 control-label required text-left">Name</label> values="ctrl.formValues.Name"
<div class="col-sm-8"> on-change="(ctrl.onChangeAppName)"
<input is-edit="ctrl.state.isEdit"
type="text" validation-data="{existingNames: ctrl.applicationNames, isEdit: ctrl.state.isEdit, originalName: ctrl.application.Name}"
class="form-control" ></name-form-section>
name="application_name"
ng-model="ctrl.formValues.Name"
ng-change="ctrl.onChangeName()"
placeholder="my-app"
ng-pattern="/^[a-z]([a-z0-9-]{0,61}[a-z0-9])?$/"
auto-focus
required
ng-disabled="ctrl.state.isEdit"
data-cy="k8sAppCreate-applicationName"
/>
</div>
</div>
<div class="form-group" ng-show="kubernetesApplicationCreationForm.application_name.$invalid || ctrl.state.alreadyExists">
<div class="small">
<div class="col-sm-3 col-lg-2">&nbsp;</div>
<div class="col-sm-8" ng-messages="kubernetesApplicationCreationForm.application_name.$error">
<p class="text-warning vertical-center" ng-message="required"
><pr-icon class="vertical-center" icon="'alert-triangle'" mode="'warning'"></pr-icon> This field is required.</p
>
<p class="text-warning vertical-center" ng-message="pattern">
<pr-icon class="vertical-center" icon="'alert-triangle'" mode="'warning'"></pr-icon>
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').
</p>
</div>
<div class="col-sm-8" ng-if="ctrl.state.alreadyExists">
<p class="text-warning vertical-center">
<pr-icon class="vertical-center" icon="'alert-triangle'" mode="'warning'"></pr-icon>
An application with the same name already exists inside the selected namespace.
</p>
</div>
</div>
</div>
<!-- #endregion --> <!-- #endregion -->
<!-- #region IMAGE FIELD --> <!-- #region IMAGE FIELD -->
@ -356,7 +208,7 @@
ng-if="ctrl.formValues.ResourcePool" ng-if="ctrl.formValues.ResourcePool"
auto-complete="false" auto-complete="false"
label-class="col-sm-3 col-lg-2" 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" namespace="ctrl.formValues.ResourcePool.Namespace.Name"
endpoint="ctrl.endpoint" endpoint="ctrl.endpoint"
is-admin="ctrl.isAdmin" is-admin="ctrl.isAdmin"
@ -373,29 +225,13 @@
<div ng-if="ctrl.formValues.ResourcePool"> <div ng-if="ctrl.formValues.ResourcePool">
<!-- #region STACK --> <!-- #region STACK -->
<div class="form-group" ng-if="!ctrl.deploymentOptions.hideStacksFunctionality"> <kube-stack-name
<div class="col-sm-12 small text-muted vertical-center"> ng-if="ctrl.state.appType !== ctrl.KubernetesDeploymentTypes.APPLICATION_FORM"
<pr-icon icon="'info'" mode="'primary'"></pr-icon> stack-name="ctrl.formValues.StackName"
Portainer can automatically bundle multiple applications inside a stack. Enter a name of a new stack or select an existing stack in the list. Leave empty to set-stack-name="(ctrl.onChangeStackName)"
use the application name. stacks="ctrl.stacks"
</div> input-class-name="'col-lg-10 col-sm-9'"
</div> ></kube-stack-name>
<div class="form-group" ng-if="!ctrl.deploymentOptions.hideStacksFunctionality">
<label for="stack_name" class="col-sm-3 col-lg-2 control-label text-left">Stack</label>
<div class="col-sm-8">
<input
type="text"
class="form-control"
placeholder="myStack"
ng-model="ctrl.formValues.StackName"
name="stack_name"
uib-typeahead="stack for stack in ctrl.stacks | filter:$viewValue | limitTo:7"
typeahead-min-length="0"
data-cy="k8sAppCreate-stackName"
/>
</div>
</div>
<!-- #endregion --> <!-- #endregion -->
<!-- #region ENVIRONMENT VARIABLES --> <!-- #region ENVIRONMENT VARIABLES -->

View File

@ -151,6 +151,7 @@ class KubernetesCreateApplicationController {
this.getAppType = this.getAppType.bind(this); this.getAppType = this.getAppType.bind(this);
this.showDataAccessPolicySection = this.showDataAccessPolicySection.bind(this); this.showDataAccessPolicySection = this.showDataAccessPolicySection.bind(this);
this.refreshReactComponent = this.refreshReactComponent.bind(this); this.refreshReactComponent = this.refreshReactComponent.bind(this);
this.onChangeNamespaceName = this.onChangeNamespaceName.bind(this);
this.$scope.$watch( this.$scope.$watch(
() => this.formValues, () => this.formValues,
@ -168,6 +169,15 @@ class KubernetesCreateApplicationController {
this.$timeout(() => { this.$timeout(() => {
this.isTemporaryRefresh = false; this.isTemporaryRefresh = false;
}, 10); }, 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) { onChangePlacements(values) {
@ -254,21 +264,16 @@ class KubernetesCreateApplicationController {
} }
imageValidityIsValid() { 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() { onChangeAppName(appName) {
const existingApplication = _.find(this.applications, { Name: this.formValues.Name }); return this.$async(async () => {
this.state.alreadyExists = (this.state.isEdit && existingApplication && this.application.Id !== existingApplication.Id) || (!this.state.isEdit && existingApplication); this.formValues.Name = appName;
});
} }
/* #region AUTO SCALER UI MANAGEMENT */ /* #region AUTO SCALER UI MANAGEMENT */
unselectAutoScaler() {
if (this.formValues.DeploymentType === this.ApplicationDeploymentTypes.Global) {
this.formValues.AutoScaler.isUsed = false;
}
}
onAutoScaleChange(values) { onAutoScaleChange(values) {
return this.$async(async () => { return this.$async(async () => {
if (!this.formValues.AutoScaler.isUsed && values.isUsed) { if (!this.formValues.AutoScaler.isUsed && values.isUsed) {
@ -295,32 +300,6 @@ class KubernetesCreateApplicationController {
clearConfigMaps() { clearConfigMaps() {
this.formValues.ConfigMaps = []; 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 */ /* #endregion */
/* #region SECRET UI MANAGEMENT */ /* #region SECRET UI MANAGEMENT */
@ -421,7 +400,6 @@ class KubernetesCreateApplicationController {
/* #region STATE VALIDATION FUNCTIONS */ /* #region STATE VALIDATION FUNCTIONS */
isValid() { isValid() {
return ( return (
!this.state.alreadyExists &&
!this.state.duplicates.environmentVariables.hasRefs && !this.state.duplicates.environmentVariables.hasRefs &&
!this.state.duplicates.persistedFolders.hasRefs && !this.state.duplicates.persistedFolders.hasRefs &&
!this.state.duplicates.configMapPaths.hasRefs && !this.state.duplicates.configMapPaths.hasRefs &&
@ -434,10 +412,6 @@ class KubernetesCreateApplicationController {
return this.storageClasses && this.storageClasses.length > 0; return this.storageClasses && this.storageClasses.length > 0;
} }
hasMultipleStorageClassesAvailable() {
return this.storageClasses && this.storageClasses.length > 1;
}
resetDeploymentType() { resetDeploymentType() {
this.formValues.DeploymentType = this.ApplicationDeploymentTypes.Replicated; this.formValues.DeploymentType = this.ApplicationDeploymentTypes.Replicated;
} }
@ -740,6 +714,7 @@ class KubernetesCreateApplicationController {
return this.$async(async () => { return this.$async(async () => {
try { try {
this.applications = await this.KubernetesApplicationService.get(namespace); this.applications = await this.KubernetesApplicationService.get(namespace);
this.applicationNames = _.map(this.applications, 'Name');
} catch (err) { } catch (err) {
this.Notifications.error('Failure', err, 'Unable to retrieve applications'); this.Notifications.error('Failure', err, 'Unable to retrieve applications');
} }
@ -796,7 +771,6 @@ class KubernetesCreateApplicationController {
this.refreshIngresses(namespace), this.refreshIngresses(namespace),
this.refreshVolumes(namespace), this.refreshVolumes(namespace),
]); ]);
this.onChangeName();
}); });
} }
@ -806,13 +780,13 @@ class KubernetesCreateApplicationController {
this.resetPersistedFolders(); this.resetPersistedFolders();
} }
onResourcePoolSelectionChange() { onChangeNamespaceName(namespaceName) {
return this.$async(async () => { return this.$async(async () => {
const namespaceWithQuota = await this.KubernetesResourcePoolService.get(this.formValues.ResourcePool.Namespace.Name); this.formValues.ResourcePool.Namespace.Name = namespaceName;
const namespace = this.formValues.ResourcePool.Namespace.Name; const namespaceWithQuota = await this.KubernetesResourcePoolService.get(namespaceName);
this.updateNamespaceLimits(namespaceWithQuota); this.updateNamespaceLimits(namespaceWithQuota);
this.updateSliders(namespaceWithQuota); this.updateSliders(namespaceWithQuota);
await this.refreshNamespaceData(namespace); await this.refreshNamespaceData(namespaceName);
this.resetFormValues(); this.resetFormValues();
}); });
} }

View File

@ -181,6 +181,7 @@ export const ngModule = angular
'isClearable', 'isClearable',
'components', 'components',
'isLoading', 'isLoading',
'noOptionsMessage',
]) ])
) )
.component( .component(

View File

@ -1,6 +1,6 @@
import { IFormController, IComponentOptions, IModule } from 'angular'; import { IFormController, IComponentOptions, IModule } from 'angular';
import { FormikErrors } from 'formik'; import { FormikErrors } from 'formik';
import { SchemaOf } from 'yup'; import { SchemaOf, object } from 'yup';
import _ from 'lodash'; import _ from 'lodash';
import { ComponentType } from 'react'; import { ComponentType } from 'react';
@ -40,7 +40,8 @@ export function withFormValidation<TProps, TValue, TData = never>(
Component: ComponentType<WithFormFieldProps<TProps, TValue>>, Component: ComponentType<WithFormFieldProps<TProps, TValue>>,
componentName: string, componentName: string,
propNames: PropNames<TProps>[], propNames: PropNames<TProps>[],
schemaBuilder: (validationData?: TData) => SchemaOf<TValue> schemaBuilder: (validationData?: TData) => SchemaOf<TValue>,
isPrimitive = false
) { ) {
const reactComponentName = `react${_.upperFirst(componentName)}`; const reactComponentName = `react${_.upperFirst(componentName)}`;
@ -54,7 +55,8 @@ export function withFormValidation<TProps, TValue, TData = never>(
createFormValidationComponent( createFormValidationComponent(
reactComponentName, reactComponentName,
propNames, propNames,
schemaBuilder schemaBuilder,
isPrimitive
) )
); );
} }
@ -62,7 +64,8 @@ export function withFormValidation<TProps, TValue, TData = never>(
export function createFormValidationComponent<TFormModel, TData = never>( export function createFormValidationComponent<TFormModel, TData = never>(
componentName: string, componentName: string,
propNames: Array<string>, propNames: Array<string>,
schemaBuilder: (validationData?: TData) => SchemaOf<TFormModel> schemaBuilder: (validationData?: TData) => SchemaOf<TFormModel>,
isPrimitive = false
): IComponentOptions { ): IComponentOptions {
const kebabName = _.kebabCase(componentName); const kebabName = _.kebabCase(componentName);
const propsWithErrors = [...propNames, 'errors', 'values']; const propsWithErrors = [...propNames, 'errors', 'values'];
@ -76,7 +79,7 @@ export function createFormValidationComponent<TFormModel, TData = never>(
on-change="($ctrl.handleChange)" on-change="($ctrl.handleChange)"
></${kebabName}> ></${kebabName}>
</ng-form>`, </ng-form>`,
controller: createFormValidatorController(schemaBuilder), controller: createFormValidatorController(schemaBuilder, isPrimitive),
bindings: Object.fromEntries( bindings: Object.fromEntries(
[...propsWithErrors, 'validationData', 'onChange'].map((p) => [p, '<']) [...propsWithErrors, 'validationData', 'onChange'].map((p) => [p, '<'])
), ),
@ -84,10 +87,11 @@ export function createFormValidationComponent<TFormModel, TData = never>(
} }
function createFormValidatorController<TFormModel, TData = never>( function createFormValidatorController<TFormModel, TData = never>(
schemaBuilder: (validationData?: TData) => SchemaOf<TFormModel> schemaBuilder: (validationData?: TData) => SchemaOf<TFormModel>,
isPrimitive = false
) { ) {
return class FormValidatorController { return class FormValidatorController {
errors?: FormikErrors<TFormModel> = {}; errors?: FormikErrors<TFormModel>;
$async: <T>(fn: () => Promise<T>) => Promise<T>; $async: <T>(fn: () => Promise<T>) => Promise<T>;
@ -118,12 +122,17 @@ function createFormValidatorController<TFormModel, TData = never>(
return this.$async(async () => { return this.$async(async () => {
this.form?.$setValidity('form', true, this.form); this.form?.$setValidity('form', true, this.form);
this.errors = await validateForm<TFormModel>( const schema = schemaBuilder(this.validationData);
() => schemaBuilder(this.validationData), this.errors = undefined;
value const errors = await (isPrimitive
); ? validateForm<{ value: TFormModel }>(
() => object({ value: schema }),
{ value }
).then((r) => r?.value)
: validateForm<TFormModel>(() => schema, value));
if (this.errors && Object.keys(this.errors).length > 0) { if (errors && Object.keys(errors).length > 0) {
this.errors = errors as FormikErrors<TFormModel> | undefined;
this.form?.$setValidity('form', false, this.form); this.form?.$setValidity('form', false, this.form);
} }
}); });

View File

@ -15,7 +15,7 @@ import { buildConfirmButton } from './modals/utils';
const otherEditorConfig = { const otherEditorConfig = {
tooltip: ( tooltip: (
<> <>
<div>Ctrl+F - Start searching</div> <div>CtrlF - Start searching</div>
<div>Ctrl+G - Find next</div> <div>Ctrl+G - Find next</div>
<div>Ctrl+Shift+G - Find previous</div> <div>Ctrl+Shift+G - Find previous</div>
<div>Ctrl+Shift+F - Replace</div> <div>Ctrl+Shift+F - Replace</div>
@ -29,7 +29,7 @@ const otherEditorConfig = {
searchCmdLabel: 'Ctrl+F for search', searchCmdLabel: 'Ctrl+F for search',
} as const; } as const;
const editorConfig = { export const editorConfig = {
mac: { mac: {
tooltip: ( tooltip: (
<> <>
@ -59,6 +59,7 @@ interface Props {
placeholder?: string; placeholder?: string;
yaml?: boolean; yaml?: boolean;
readonly?: boolean; readonly?: boolean;
titleContent?: React.ReactNode;
hideTitle?: boolean; hideTitle?: boolean;
error?: string; error?: string;
height?: string; height?: string;
@ -69,6 +70,7 @@ export function WebEditorForm({
onChange, onChange,
placeholder, placeholder,
value, value,
titleContent = '',
hideTitle, hideTitle,
readonly, readonly,
yaml, yaml,
@ -80,16 +82,11 @@ export function WebEditorForm({
<div> <div>
<div className="web-editor overflow-x-hidden"> <div className="web-editor overflow-x-hidden">
{!hideTitle && ( {!hideTitle && (
<FormSectionTitle htmlFor={id}> <>
Web editor <DefaultTitle id={id} />
<div className="text-muted small vertical-center ml-auto"> {titleContent ?? null}
{editorConfig[BROWSER_OS_PLATFORM].searchCmdLabel} </>
<Tooltip message={editorConfig[BROWSER_OS_PLATFORM].tooltip} />
</div>
</FormSectionTitle>
)} )}
{children && ( {children && (
<div className="form-group text-muted small"> <div className="form-group text-muted small">
<div className="col-sm-12 col-lg-12">{children}</div> <div className="col-sm-12 col-lg-12">{children}</div>
@ -116,6 +113,19 @@ export function WebEditorForm({
); );
} }
function DefaultTitle({ id }: { id: string }) {
return (
<FormSectionTitle htmlFor={id}>
Web editor
<div className="text-muted small vertical-center ml-auto">
{editorConfig[BROWSER_OS_PLATFORM].searchCmdLabel}
<Tooltip message={editorConfig[BROWSER_OS_PLATFORM].tooltip} />
</div>
</FormSectionTitle>
);
}
export function usePreventExit( export function usePreventExit(
initialValue: string, initialValue: string,
value: string, value: string,

View File

@ -2,6 +2,7 @@ import { ComponentProps, PropsWithChildren, ReactNode } from 'react';
import clsx from 'clsx'; import clsx from 'clsx';
import { Tooltip } from '@@/Tip/Tooltip'; import { Tooltip } from '@@/Tip/Tooltip';
import { InlineLoader } from '@@/InlineLoader';
import { FormError } from '../FormError'; import { FormError } from '../FormError';
@ -17,6 +18,8 @@ export interface Props {
errors?: ReactNode; errors?: ReactNode;
required?: boolean; required?: boolean;
className?: string; 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({ export function FormControl({
@ -29,6 +32,8 @@ export function FormControl({
className, className,
required = false, required = false,
setTooltipHtmlMessage, setTooltipHtmlMessage,
isLoading = false,
loadingText = 'Loading...',
}: PropsWithChildren<Props>) { }: PropsWithChildren<Props>) {
return ( return (
<div <div
@ -52,7 +57,8 @@ export function FormControl({
</label> </label>
<div className={sizeClassChildren(size)}> <div className={sizeClassChildren(size)}>
{children} {isLoading && <InlineLoader>{loadingText}</InlineLoader>}
{!isLoading && children}
{errors && <FormError>{errors}</FormError>} {errors && <FormError>{errors}</FormError>}
</div> </div>
</div> </div>

View File

@ -28,6 +28,7 @@ interface SharedProps extends AutomationTestingProps {
isClearable?: boolean; isClearable?: boolean;
bindToBody?: boolean; bindToBody?: boolean;
isLoading?: boolean; isLoading?: boolean;
noOptionsMessage?: () => string;
} }
interface MultiProps<TValue> extends SharedProps { interface MultiProps<TValue> extends SharedProps {
@ -85,6 +86,7 @@ export function SingleSelect<TValue = string>({
bindToBody, bindToBody,
components, components,
isLoading, isLoading,
noOptionsMessage,
}: SingleProps<TValue>) { }: SingleProps<TValue>) {
const selectedValue = const selectedValue =
value || (typeof value === 'number' && value === 0) value || (typeof value === 'number' && value === 0)
@ -108,6 +110,7 @@ export function SingleSelect<TValue = string>({
menuPortalTarget={bindToBody ? document.body : undefined} menuPortalTarget={bindToBody ? document.body : undefined}
components={components} components={components}
isLoading={isLoading} isLoading={isLoading}
noOptionsMessage={noOptionsMessage}
/> />
); );
} }
@ -148,6 +151,7 @@ export function MultiSelect<TValue = string>({
bindToBody, bindToBody,
components, components,
isLoading, isLoading,
noOptionsMessage,
}: Omit<MultiProps<TValue>, 'isMulti'>) { }: Omit<MultiProps<TValue>, 'isMulti'>) {
const selectedOptions = findSelectedOptions(options, value); const selectedOptions = findSelectedOptions(options, value);
return ( return (
@ -169,6 +173,7 @@ export function MultiSelect<TValue = string>({
menuPortalTarget={bindToBody ? document.body : undefined} menuPortalTarget={bindToBody ? document.body : undefined}
components={components} components={components}
isLoading={isLoading} isLoading={isLoading}
noOptionsMessage={noOptionsMessage}
/> />
); );
} }

View File

@ -1,15 +1,31 @@
import { useMemo } from 'react';
import { useCurrentUser } from '@/react/hooks/useUser';
import { InsightsBox } from '@@/InsightsBox'; import { InsightsBox } from '@@/InsightsBox';
import { Link } from '@@/Link'; import { Link } from '@@/Link';
import { TextTip } from '@@/Tip/TextTip'; import { TextTip } from '@@/Tip/TextTip';
import { Tooltip } from '@@/Tip/Tooltip'; import { Tooltip } from '@@/Tip/Tooltip';
import { AutocompleteSelect } from '@@/form-components/AutocompleteSelect';
type Props = { type Props = {
stackName: string; stackName: string;
setStackName: (name: string) => void; 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 = ( const tooltip = (
<> <>
You may specify a stack name to label resources that you want to group. 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 Stack
<Tooltip message={tooltip} setHtmlMessage /> <Tooltip message={tooltip} setHtmlMessage />
</label> </label>
<div className="col-sm-8"> <div className={inputClassName || 'col-sm-8'}>
<input <AutocompleteSelect
type="text" searchResults={stackResults?.map((result) => ({
className="form-control" value: result,
defaultValue={stackName} label: result,
onChange={(e) => setStackName(e.target.value)} }))}
id="stack_name" value={stackName ?? ''}
placeholder="myStack" onChange={setStackName}
placeholder="e.g. myStack"
inputId="stack_name"
/> />
</div> </div>
</div> </div>

View File

@ -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 (
<div>
<WebEditorForm
value={values}
readonly={!isAllowedToEdit}
titleContent={<TitleContent isComposeFormat={isComposeFormat} />}
onChange={(values) => onChange(values)}
id={formId}
placeholder="Define or paste the content of your manifest file here"
yaml
/>
</div>
);
}
function TitleContent({ isComposeFormat }: { isComposeFormat?: boolean }) {
return (
<>
{isComposeFormat && (
<TextTip color="orange">
<p>
Portainer no longer supports{' '}
<a
href="https://docs.docker.com/compose/compose-file/"
target="_blank"
rel="noreferrer"
>
docker-compose
</a>{' '}
format manifests for Kubernetes deployments, and we have removed the{' '}
<a href="https://kompose.io/" target="_blank" rel="noreferrer">
Kompose
</a>{' '}
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).
</p>
<p>
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&apos;ve submitted) are taking months to be merged, with new CVEs
arising in the meantime.
</p>
<p>
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.
</p>
</TextTip>
)}
{!isComposeFormat && (
<TextTip color="blue">
<p>
This feature allows you to deploy any kind of Kubernetes resource in
this environment (Deployment, Secret, ConfigMap...).
</p>
<p>
You can get more information about Kubernetes file format in the{' '}
<a
href="https://kubernetes.io/docs/concepts/overview/working-with-objects/kubernetes-objects/"
target="_blank"
rel="noreferrer"
>
official documentation
</a>
.
</p>
</TextTip>
)}
</>
);
}

View File

@ -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<string>;
isEdit: boolean;
};
export function NameFormSection({
onChange,
values: appName,
errors,
isEdit,
}: Props) {
return (
<FormControl
label="Name"
inputId="application_name"
errors={errors}
required
>
<Input
type="text"
value={appName ?? ''}
onChange={(e) => onChange(e.target.value)}
autoFocus
placeholder="e.g. my-app"
disabled={isEdit}
id="application_name"
data-cy="k8sAppCreate-applicationName"
/>
</FormControl>
);
}

View File

@ -0,0 +1,2 @@
export { NameFormSection } from './NameFormSection';
export { appNameValidation } from './nameValidation';

View File

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

View File

@ -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<string>;
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 (
<FormControl
label="Namespace"
inputId="namespace-selector"
isLoading={namespacesQuery.isLoading}
errors={errors}
>
{namespaceNames.length > 0 && (
<PortainerSelect
value={value}
options={namespaceNames}
onChange={onChange}
disabled={isEdit}
noOptionsMessage={() => 'No namespaces found'}
placeholder="No namespaces found" // will only show when there are no options
inputId="namespace-selector"
data-cy="k8sAppCreate-nsSelect"
/>
)}
</FormControl>
);
}

View File

@ -0,0 +1,2 @@
export { NamespaceSelector } from './NamespaceSelector';
export { namespaceSelectorValidation } from './namespaceSelectorValidation';

View File

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

View File

@ -54,7 +54,7 @@ export function YAMLInspector({ identifier, data, hideMessage }: Props) {
); );
} }
function cleanYamlUnwantedFields(yml: string) { export function cleanYamlUnwantedFields(yml: string) {
try { try {
const ymls = yml.split('---'); const ymls = yml.split('---');
const cleanYmls = ymls.map((yml) => { const cleanYmls = ymls.map((yml) => {

View File

@ -29,6 +29,6 @@ async function getRegistry(registryId: Registry['Id'], environmentId: number) {
}); });
return data; return data;
} catch (err) { } catch (err) {
throw parseAxiosError(err as Error, 'XXXUnable to retrieve registry'); throw parseAxiosError(err as Error, 'Unable to retrieve registry');
} }
} }