mirror of https://github.com/portainer/portainer
feat(custom-templates): migrate create view to react [EE-6400] (#10715)
parent
bd5ba7b5d0
commit
dabcf4f7db
|
@ -126,7 +126,7 @@ angular.module('portainer.docker', ['portainer.app', reactModule]).config([
|
|||
|
||||
views: {
|
||||
'content@': {
|
||||
component: 'createCustomTemplateView',
|
||||
component: 'createCustomTemplatesView',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
@ -174,7 +174,7 @@ angular
|
|||
|
||||
views: {
|
||||
'content@': {
|
||||
component: 'edgeCreateCustomTemplatesView',
|
||||
component: 'createCustomTemplatesView',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
@ -4,9 +4,9 @@ import { r2a } from '@/react-tools/react2angular';
|
|||
import { withCurrentUser } from '@/react-tools/withCurrentUser';
|
||||
import { withUIRouter } from '@/react-tools/withUIRouter';
|
||||
import { ListView } from '@/react/edge/templates/custom-templates/ListView';
|
||||
import { CreateView } from '@/react/edge/templates/custom-templates/CreateView';
|
||||
import { EditView } from '@/react/edge/templates/custom-templates/EditView';
|
||||
import { EditView as EdgeEditView } from '@/react/edge/templates/custom-templates/EditView';
|
||||
import { AppTemplatesView } from '@/react/edge/templates/AppTemplatesView';
|
||||
import { CreateView } from '@/react/portainer/templates/custom-templates/CreateView/CreateView';
|
||||
|
||||
export const templatesModule = angular
|
||||
.module('portainer.app.react.components.templates', [])
|
||||
|
@ -19,10 +19,10 @@ export const templatesModule = angular
|
|||
r2a(withCurrentUser(withUIRouter(ListView)), [])
|
||||
)
|
||||
.component(
|
||||
'edgeCreateCustomTemplatesView',
|
||||
'createCustomTemplatesView',
|
||||
r2a(withCurrentUser(withUIRouter(CreateView)), [])
|
||||
)
|
||||
.component(
|
||||
'edgeEditCustomTemplatesView',
|
||||
r2a(withCurrentUser(withUIRouter(EditView)), [])
|
||||
r2a(withCurrentUser(withUIRouter(EdgeEditView)), [])
|
||||
).name;
|
||||
|
|
|
@ -2,14 +2,12 @@ import angular from 'angular';
|
|||
|
||||
import { kubeCustomTemplatesView } from './kube-custom-templates-view';
|
||||
import { kubeEditCustomTemplateView } from './kube-edit-custom-template-view';
|
||||
import { kubeCreateCustomTemplateView } from './kube-create-custom-template-view';
|
||||
|
||||
export default angular
|
||||
.module('portainer.kubernetes.custom-templates', [])
|
||||
.config(config)
|
||||
.component('kubeCustomTemplatesView', kubeCustomTemplatesView)
|
||||
.component('kubeEditCustomTemplateView', kubeEditCustomTemplateView)
|
||||
.component('kubeCreateCustomTemplateView', kubeCreateCustomTemplateView).name;
|
||||
.component('kubeEditCustomTemplateView', kubeEditCustomTemplateView).name;
|
||||
|
||||
function config($stateRegistryProvider) {
|
||||
const templates = {
|
||||
|
@ -38,7 +36,7 @@ function config($stateRegistryProvider) {
|
|||
|
||||
views: {
|
||||
'content@': {
|
||||
component: 'kubeCreateCustomTemplateView',
|
||||
component: 'createCustomTemplatesView',
|
||||
},
|
||||
},
|
||||
params: {
|
||||
|
|
|
@ -1,6 +0,0 @@
|
|||
import controller from './kube-create-custom-template-view.controller.js';
|
||||
|
||||
export const kubeCreateCustomTemplateView = {
|
||||
templateUrl: './kube-create-custom-template-view.html',
|
||||
controller,
|
||||
};
|
|
@ -1,242 +0,0 @@
|
|||
import { AccessControlFormData } from '@/portainer/components/accessControlForm/porAccessControlFormModel';
|
||||
import { getTemplateVariables, intersectVariables, isTemplateVariablesEnabled } from '@/react/portainer/custom-templates/components/utils';
|
||||
import { editor, upload, git } from '@@/BoxSelector/common-options/build-methods';
|
||||
import { confirmWebEditorDiscard } from '@@/modals/confirm';
|
||||
import { KUBE_TEMPLATE_NAME_VALIDATION_REGEX } from '@/constants';
|
||||
|
||||
class KubeCreateCustomTemplateViewController {
|
||||
/* @ngInject */
|
||||
constructor($async, $state, Authentication, CustomTemplateService, FormValidator, Notifications, ResourceControlService) {
|
||||
Object.assign(this, { $async, $state, Authentication, CustomTemplateService, FormValidator, Notifications, ResourceControlService });
|
||||
|
||||
this.methodOptions = [editor, upload, git];
|
||||
|
||||
this.templates = null;
|
||||
this.isTemplateVariablesEnabled = isTemplateVariablesEnabled;
|
||||
|
||||
this.state = {
|
||||
method: 'editor',
|
||||
actionInProgress: false,
|
||||
formValidationError: '',
|
||||
isEditorDirty: false,
|
||||
isTemplateValid: true,
|
||||
};
|
||||
|
||||
this.formValues = {
|
||||
FileContent: '',
|
||||
File: null,
|
||||
Title: '',
|
||||
Description: '',
|
||||
Note: '',
|
||||
Logo: '',
|
||||
AccessControlData: new AccessControlFormData(),
|
||||
Variables: [],
|
||||
RepositoryURL: '',
|
||||
RepositoryURLValid: false,
|
||||
RepositoryReferenceName: 'refs/heads/main',
|
||||
RepositoryAuthentication: false,
|
||||
RepositoryUsername: '',
|
||||
RepositoryPassword: '',
|
||||
ComposeFilePathInRepository: 'manifest.yml',
|
||||
};
|
||||
|
||||
this.validationData = {
|
||||
title: {
|
||||
pattern: KUBE_TEMPLATE_NAME_VALIDATION_REGEX,
|
||||
error:
|
||||
"This field must consist of lower-case alphanumeric characters, '.', '_' or '-', must start and end with an alphanumeric character and must be 63 characters or less (e.g. 'my-name', or 'abc-123').",
|
||||
},
|
||||
};
|
||||
|
||||
this.onChangeFile = this.onChangeFile.bind(this);
|
||||
this.onChangeFileContent = this.onChangeFileContent.bind(this);
|
||||
this.onChangeMethod = this.onChangeMethod.bind(this);
|
||||
this.onBeforeOnload = this.onBeforeOnload.bind(this);
|
||||
this.handleChange = this.handleChange.bind(this);
|
||||
this.onVariablesChange = this.onVariablesChange.bind(this);
|
||||
this.onChangePlatform = this.onChangePlatform.bind(this);
|
||||
this.onChangeType = this.onChangeType.bind(this);
|
||||
}
|
||||
|
||||
onChangePlatform(value) {
|
||||
this.handleChange({ Platform: value });
|
||||
}
|
||||
|
||||
onChangeType(value) {
|
||||
this.handleChange({ Type: value });
|
||||
}
|
||||
|
||||
onChangeMethod(method) {
|
||||
this.state.method = method;
|
||||
this.formValues.Variables = [];
|
||||
}
|
||||
|
||||
onChangeFileContent(content) {
|
||||
this.handleChange({ FileContent: content });
|
||||
this.parseTemplate(content);
|
||||
this.state.isEditorDirty = true;
|
||||
}
|
||||
|
||||
parseTemplate(templateStr) {
|
||||
if (!this.isTemplateVariablesEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [variables] = getTemplateVariables(templateStr);
|
||||
|
||||
const isValid = !!variables;
|
||||
|
||||
this.state.isTemplateValid = isValid;
|
||||
|
||||
if (isValid) {
|
||||
this.onVariablesChange(intersectVariables(this.formValues.Variables, variables));
|
||||
}
|
||||
}
|
||||
|
||||
onVariablesChange(value) {
|
||||
this.handleChange({ Variables: value });
|
||||
}
|
||||
|
||||
onChangeFile(file) {
|
||||
this.handleChange({ File: file });
|
||||
}
|
||||
|
||||
handleChange(values) {
|
||||
return this.$async(async () => {
|
||||
this.formValues = {
|
||||
...this.formValues,
|
||||
...values,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async createCustomTemplate() {
|
||||
return this.$async(async () => {
|
||||
const { method } = this.state;
|
||||
|
||||
if (!this.validateForm(method)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.state.actionInProgress = true;
|
||||
try {
|
||||
const customTemplate = await this.createCustomTemplateByMethod(method, this.formValues);
|
||||
|
||||
const accessControlData = this.formValues.AccessControlData;
|
||||
const userDetails = this.Authentication.getUserDetails();
|
||||
const userId = userDetails.ID;
|
||||
await this.ResourceControlService.applyResourceControl(userId, accessControlData, customTemplate.ResourceControl);
|
||||
|
||||
this.Notifications.success('Success', 'Custom template successfully created');
|
||||
this.state.isEditorDirty = false;
|
||||
this.$state.go('kubernetes.templates.custom');
|
||||
} catch (err) {
|
||||
this.Notifications.error('Failure', err, 'Failed creating custom template');
|
||||
} finally {
|
||||
this.state.actionInProgress = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
createCustomTemplateByMethod(method, template) {
|
||||
template.Type = 3;
|
||||
|
||||
switch (method) {
|
||||
case 'editor':
|
||||
return this.createCustomTemplateFromFileContent(template);
|
||||
case 'upload':
|
||||
return this.createCustomTemplateFromFileUpload(template);
|
||||
case 'repository':
|
||||
return this.createCustomTemplateFromGitRepository(template);
|
||||
}
|
||||
}
|
||||
|
||||
createCustomTemplateFromFileContent(template) {
|
||||
return this.CustomTemplateService.createCustomTemplateFromFileContent(template);
|
||||
}
|
||||
|
||||
createCustomTemplateFromFileUpload(template) {
|
||||
return this.CustomTemplateService.createCustomTemplateFromFileUpload(template);
|
||||
}
|
||||
|
||||
createCustomTemplateFromGitRepository(template) {
|
||||
return this.CustomTemplateService.createCustomTemplateFromGitRepository(template);
|
||||
}
|
||||
|
||||
validateForm(method) {
|
||||
this.state.formValidationError = '';
|
||||
|
||||
if (method === 'editor' && this.formValues.FileContent === '') {
|
||||
this.state.formValidationError = 'Template file content must not be empty';
|
||||
return false;
|
||||
}
|
||||
|
||||
const title = this.formValues.Title;
|
||||
const isNotUnique = this.templates.some((template) => template.Title === title);
|
||||
if (isNotUnique) {
|
||||
this.state.formValidationError = 'A template with the same name already exists';
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!this.state.isTemplateValid) {
|
||||
this.state.formValidationError = 'Template is not valid';
|
||||
return false;
|
||||
}
|
||||
|
||||
const isAdmin = this.Authentication.isAdmin();
|
||||
const accessControlData = this.formValues.AccessControlData;
|
||||
const error = this.FormValidator.validateAccessControl(accessControlData, isAdmin);
|
||||
|
||||
if (error) {
|
||||
this.state.formValidationError = error;
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async $onInit() {
|
||||
return this.$async(async () => {
|
||||
const { fileContent, type } = this.$state.params;
|
||||
|
||||
this.formValues.FileContent = fileContent;
|
||||
this.parseTemplate(fileContent);
|
||||
if (type) {
|
||||
this.formValues.Type = +type;
|
||||
}
|
||||
|
||||
try {
|
||||
this.templates = await this.CustomTemplateService.customTemplates(3);
|
||||
} catch (err) {
|
||||
this.Notifications.error('Failure loading', err, 'Failed loading custom templates');
|
||||
}
|
||||
|
||||
this.state.loading = false;
|
||||
|
||||
window.addEventListener('beforeunload', this.onBeforeOnload);
|
||||
});
|
||||
}
|
||||
|
||||
$onDestroy() {
|
||||
window.removeEventListener('beforeunload', this.onBeforeOnload);
|
||||
}
|
||||
|
||||
isEditorDirty() {
|
||||
return this.state.method === 'editor' && this.formValues.FileContent && this.state.isEditorDirty;
|
||||
}
|
||||
|
||||
onBeforeOnload(event) {
|
||||
if (this.isEditorDirty()) {
|
||||
event.preventDefault();
|
||||
event.returnValue = '';
|
||||
}
|
||||
}
|
||||
|
||||
uiCanExit() {
|
||||
if (this.isEditorDirty()) {
|
||||
return confirmWebEditorDiscard();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default KubeCreateCustomTemplateViewController;
|
|
@ -1,73 +0,0 @@
|
|||
<page-header title="'Create Custom template'" breadcrumbs="[{label:'Custom Templates', link:'kubernetes.templates.custom'}, 'Create Custom template']" reload="true"> </page-header>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-sm-12">
|
||||
<rd-widget>
|
||||
<rd-widget-body>
|
||||
<form class="form-horizontal" name="$ctrl.form">
|
||||
<custom-templates-common-fields values="$ctrl.formValues" on-change="($ctrl.handleChange)" validation-data="$ctrl.validationData"></custom-templates-common-fields>
|
||||
|
||||
<!-- build-method -->
|
||||
<div class="col-sm-12 form-section-title"> Build method </div>
|
||||
<box-selector slim="true" radio-name="'method'" value="$ctrl.state.method" options="$ctrl.methodOptions" on-change="($ctrl.onChangeMethod)"></box-selector>
|
||||
|
||||
<div class="mt-4">
|
||||
<web-editor-form
|
||||
ng-if="$ctrl.state.method === 'editor'"
|
||||
identifier="template-creation-editor"
|
||||
value="$ctrl.formValues.FileContent"
|
||||
on-change="($ctrl.onChangeFileContent)"
|
||||
ng-required="true"
|
||||
yml="true"
|
||||
placeholder="Define or paste the content of your manifest file here"
|
||||
>
|
||||
<editor-description>
|
||||
<p>Templates allow deploying any kind of Kubernetes resource (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>
|
||||
</editor-description>
|
||||
</web-editor-form>
|
||||
</div>
|
||||
|
||||
<file-upload-form ng-if="$ctrl.state.method === 'upload'" file="$ctrl.formValues.File" on-change="($ctrl.onChangeFile)" ng-required="true">
|
||||
<file-upload-description> You can upload a Manifest file from your computer. </file-upload-description>
|
||||
</file-upload-form>
|
||||
|
||||
<git-form deploy-method="kubernetes" ng-if="$ctrl.state.method === 'repository'" value="$ctrl.formValues" on-change="($ctrl.handleChange)"></git-form>
|
||||
|
||||
<custom-templates-variables-definition-field
|
||||
ng-if="$ctrl.isTemplateVariablesEnabled"
|
||||
value="$ctrl.formValues.Variables"
|
||||
on-change="($ctrl.onVariablesChange)"
|
||||
is-variables-names-from-parent="$ctrl.state.method === 'editor'"
|
||||
></custom-templates-variables-definition-field>
|
||||
|
||||
<por-access-control-form form-data="$ctrl.formValues.AccessControlData"></por-access-control-form>
|
||||
|
||||
<!-- actions -->
|
||||
<div class="col-sm-12 form-section-title"> Actions </div>
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary btn-sm !ml-0"
|
||||
ng-disabled="!$ctrl.state.isTemplateValid ||$ctrl.state.actionInProgress || $ctrl.form.$invalid || ($ctrl.state.method === 'editor' && !$ctrl.formValues.FileContent)"
|
||||
ng-click="$ctrl.createCustomTemplate()"
|
||||
button-spinner="$ctrl.state.actionInProgress"
|
||||
>
|
||||
<span ng-hide="$ctrl.state.actionInProgress">Create custom template</span>
|
||||
<span ng-show="$ctrl.state.actionInProgress">Creation in progress...</span>
|
||||
</button>
|
||||
<span class="text-danger" ng-if="$ctrl.state.formValidationError" style="margin-left: 5px">
|
||||
{{ $ctrl.state.formValidationError }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !actions -->
|
||||
</form>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
</div>
|
||||
</div>
|
|
@ -1,119 +0,0 @@
|
|||
<page-header title="'Create Custom template'" breadcrumbs="[{label:'Custom Templates', link:'docker.templates.custom'}, 'Create Custom template']" reload="true"> </page-header>
|
||||
|
||||
<div class="row" ng-if="!$ctrl.state.loading">
|
||||
<div class="col-sm-12">
|
||||
<rd-widget>
|
||||
<rd-widget-body>
|
||||
<form class="form-horizontal" name="customTemplateForm">
|
||||
<custom-templates-common-fields values="$ctrl.formValues" on-change="($ctrl.handleChange)" validation-data="$ctrl.validationData"></custom-templates-common-fields>
|
||||
|
||||
<custom-templates-platform-selector value="$ctrl.formValues.Platform" on-change="($ctrl.onChangePlatform)"></custom-templates-platform-selector>
|
||||
|
||||
<custom-templates-type-selector value="$ctrl.formValues.Type" on-change="($ctrl.onChangeType)"></custom-templates-type-selector>
|
||||
|
||||
<!-- build-method -->
|
||||
<div ng-if="!$ctrl.state.fromStack">
|
||||
<div class="col-sm-12 form-section-title"> Build method </div>
|
||||
|
||||
<box-selector
|
||||
slim="true"
|
||||
options="$ctrl.buildMethods"
|
||||
value="$ctrl.state.Method"
|
||||
on-change="($ctrl.onChangeMethod)"
|
||||
radio-name="'buildMethod'"
|
||||
slim="true"
|
||||
></box-selector>
|
||||
</div>
|
||||
<!-- !build-method -->
|
||||
<!-- web-editor -->
|
||||
<web-editor-form
|
||||
ng-if="$ctrl.state.Method === 'editor'"
|
||||
identifier="custom-template-creation-editor"
|
||||
value="$ctrl.formValues.FileContent"
|
||||
on-change="($ctrl.editorUpdate)"
|
||||
ng-required="true"
|
||||
yml="true"
|
||||
placeholder="Define or paste the content of your docker compose file here"
|
||||
>
|
||||
<editor-description>
|
||||
<p>
|
||||
You can get more information about Compose file format in the
|
||||
<a href="https://docs.docker.com/compose/compose-file/" target="_blank"> official documentation </a>
|
||||
.
|
||||
</p>
|
||||
</editor-description>
|
||||
</web-editor-form>
|
||||
<!-- !web-editor -->
|
||||
<!-- upload -->
|
||||
<div ng-show="$ctrl.state.Method === 'upload'">
|
||||
<div class="col-sm-12 form-section-title"> Upload </div>
|
||||
<div class="form-group">
|
||||
<span class="col-sm-12 text-muted small"> You can upload a Compose file from your computer. </span>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<button type="button" class="btn btn-sm btn-primary" ngf-select ng-model="$ctrl.formValues.File"> Select file </button>
|
||||
<span class="space-left">
|
||||
{{ $ctrl.formValues.File.name }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !upload -->
|
||||
<!-- repository -->
|
||||
<git-form
|
||||
ng-if="$ctrl.state.Method === 'repository'"
|
||||
value="$ctrl.formValues"
|
||||
on-change="($ctrl.handleChange)"
|
||||
is-docker-standalone="$ctrl.isDockerStandalone"
|
||||
></git-form>
|
||||
|
||||
<div class="form-group" ng-if="!$ctrl.state.isTemplateValid">
|
||||
<div class="col-sm-12">
|
||||
<div class="small text-warning">
|
||||
<pr-icon icon="'alert-triangle'" class-name="'space-right'"></pr-icon>
|
||||
Template is invalid.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<custom-templates-variables-definition-field
|
||||
ng-if="$ctrl.isTemplateVariablesEnabled"
|
||||
value="$ctrl.formValues.Variables"
|
||||
on-change="($ctrl.onVariablesChange)"
|
||||
is-variables-names-from-parent="$ctrl.state.Method === 'editor'"
|
||||
></custom-templates-variables-definition-field>
|
||||
|
||||
<!-- !repository -->
|
||||
<por-access-control-form form-data="$ctrl.formValues.AccessControlData"></por-access-control-form>
|
||||
|
||||
<!-- actions -->
|
||||
<div class="col-sm-12 form-section-title"> Actions </div>
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary btn-sm !ml-0"
|
||||
ng-disabled="$ctrl.state.actionInProgress || customTemplateForm.$invalid
|
||||
|| ($ctrl.state.Method === 'editor' && !$ctrl.formValues.FileContent)
|
||||
|| ($ctrl.state.Method === 'upload' && !$ctrl.formValues.File)
|
||||
|| ($ctrl.state.Method === 'repository' && ((!$ctrl.formValues.RepositoryURL || !$ctrl.formValues.ComposeFilePathInRepository) || ($ctrl.formValues.RepositoryAuthentication && (!$ctrl.formValues.RepositoryUsername || !$ctrl.formValues.RepositoryPassword))))
|
||||
|| !$ctrl.formValues.Title
|
||||
|| !$ctrl.state.isTemplateValid"
|
||||
ng-click="$ctrl.createCustomTemplate()"
|
||||
button-spinner="$ctrl.state.actionInProgress"
|
||||
>
|
||||
<span ng-hide="$ctrl.state.actionInProgress">Create custom template</span>
|
||||
<span ng-show="$ctrl.state.actionInProgress">Creation in progress...</span>
|
||||
</button>
|
||||
<span class="text-danger space-left" ng-if="$ctrl.state.formValidationError">
|
||||
{{ $ctrl.state.formValidationError }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !actions -->
|
||||
</form>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
</div>
|
||||
</div>
|
|
@ -1,271 +0,0 @@
|
|||
import _ from 'lodash';
|
||||
import { AccessControlFormData } from 'Portainer/components/accessControlForm/porAccessControlFormModel';
|
||||
import { TEMPLATE_NAME_VALIDATION_REGEX } from '@/constants';
|
||||
import { getTemplateVariables, intersectVariables, isTemplateVariablesEnabled } from '@/react/portainer/custom-templates/components/utils';
|
||||
import { editor, upload, git } from '@@/BoxSelector/common-options/build-methods';
|
||||
import { confirmWebEditorDiscard } from '@@/modals/confirm';
|
||||
import { fetchFilePreview } from '@/react/portainer/templates/app-templates/queries/useFetchTemplateFile';
|
||||
|
||||
class CreateCustomTemplateViewController {
|
||||
/* @ngInject */
|
||||
constructor($async, $state, $scope, $window, Authentication, CustomTemplateService, FormValidator, Notifications, ResourceControlService, StackService, StateManager) {
|
||||
Object.assign(this, {
|
||||
$async,
|
||||
$state,
|
||||
$window,
|
||||
$scope,
|
||||
Authentication,
|
||||
CustomTemplateService,
|
||||
FormValidator,
|
||||
Notifications,
|
||||
ResourceControlService,
|
||||
StackService,
|
||||
StateManager,
|
||||
});
|
||||
|
||||
this.buildMethods = [editor, upload, git];
|
||||
|
||||
this.isTemplateVariablesEnabled = isTemplateVariablesEnabled;
|
||||
|
||||
this.formValues = {
|
||||
Title: '',
|
||||
FileContent: '',
|
||||
File: null,
|
||||
RepositoryURL: '',
|
||||
RepositoryReferenceName: '',
|
||||
RepositoryAuthentication: false,
|
||||
RepositoryUsername: '',
|
||||
RepositoryPassword: '',
|
||||
ComposeFilePathInRepository: 'docker-compose.yml',
|
||||
Description: '',
|
||||
Note: '',
|
||||
Logo: '',
|
||||
Platform: 1,
|
||||
Type: 1,
|
||||
AccessControlData: new AccessControlFormData(),
|
||||
Variables: [],
|
||||
TLSSkipVerify: false,
|
||||
};
|
||||
|
||||
this.state = {
|
||||
Method: 'editor',
|
||||
formValidationError: '',
|
||||
actionInProgress: false,
|
||||
fromStack: false,
|
||||
loading: true,
|
||||
isEditorDirty: false,
|
||||
isTemplateValid: true,
|
||||
};
|
||||
|
||||
this.validationData = {
|
||||
title: {
|
||||
pattern: TEMPLATE_NAME_VALIDATION_REGEX,
|
||||
error: "This field must consist of lower-case alphanumeric characters, '_' or '-' (e.g. 'my-name', or 'abc-123').",
|
||||
},
|
||||
};
|
||||
|
||||
this.templates = [];
|
||||
|
||||
this.createCustomTemplate = this.createCustomTemplate.bind(this);
|
||||
this.createCustomTemplateAsync = this.createCustomTemplateAsync.bind(this);
|
||||
this.validateForm = this.validateForm.bind(this);
|
||||
this.createCustomTemplateByMethod = this.createCustomTemplateByMethod.bind(this);
|
||||
this.createCustomTemplateFromFileContent = this.createCustomTemplateFromFileContent.bind(this);
|
||||
this.createCustomTemplateFromFileUpload = this.createCustomTemplateFromFileUpload.bind(this);
|
||||
this.createCustomTemplateFromGitRepository = this.createCustomTemplateFromGitRepository.bind(this);
|
||||
this.editorUpdate = this.editorUpdate.bind(this);
|
||||
this.onChangeMethod = this.onChangeMethod.bind(this);
|
||||
this.onVariablesChange = this.onVariablesChange.bind(this);
|
||||
this.handleChange = this.handleChange.bind(this);
|
||||
this.onChangePlatform = this.onChangePlatform.bind(this);
|
||||
this.onChangeType = this.onChangeType.bind(this);
|
||||
}
|
||||
|
||||
onVariablesChange(value) {
|
||||
this.handleChange({ Variables: value });
|
||||
}
|
||||
|
||||
onChangePlatform(value) {
|
||||
this.handleChange({ Platform: value });
|
||||
}
|
||||
|
||||
onChangeType(value) {
|
||||
this.handleChange({ Type: value });
|
||||
}
|
||||
|
||||
handleChange(values) {
|
||||
return this.$async(async () => {
|
||||
this.formValues = {
|
||||
...this.formValues,
|
||||
...values,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
createCustomTemplate() {
|
||||
return this.$async(this.createCustomTemplateAsync);
|
||||
}
|
||||
|
||||
onChangeMethod(method) {
|
||||
return this.$scope.$evalAsync(() => {
|
||||
this.formValues.FileContent = '';
|
||||
this.formValues.Variables = [];
|
||||
this.selectedTemplate = null;
|
||||
this.state.Method = method;
|
||||
});
|
||||
}
|
||||
|
||||
async createCustomTemplateAsync() {
|
||||
let method = this.state.Method;
|
||||
|
||||
if (method === 'template') {
|
||||
method = 'editor';
|
||||
}
|
||||
|
||||
if (!this.validateForm(method)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.state.actionInProgress = true;
|
||||
try {
|
||||
const customTemplate = await this.createCustomTemplateByMethod(method);
|
||||
|
||||
const accessControlData = this.formValues.AccessControlData;
|
||||
const userDetails = this.Authentication.getUserDetails();
|
||||
const userId = userDetails.ID;
|
||||
await this.ResourceControlService.applyResourceControl(userId, accessControlData, customTemplate.ResourceControl);
|
||||
|
||||
this.Notifications.success('Success', 'Custom template successfully created');
|
||||
this.state.isEditorDirty = false;
|
||||
this.$state.go('docker.templates.custom');
|
||||
} catch (err) {
|
||||
this.Notifications.error('Failure', err, 'A template with the same name already exists');
|
||||
} finally {
|
||||
this.state.actionInProgress = false;
|
||||
}
|
||||
}
|
||||
|
||||
validateForm(method) {
|
||||
this.state.formValidationError = '';
|
||||
|
||||
if (method === 'editor' && this.formValues.FileContent === '') {
|
||||
this.state.formValidationError = 'Template file content must not be empty';
|
||||
return false;
|
||||
}
|
||||
|
||||
const title = this.formValues.Title;
|
||||
const isNotUnique = _.some(this.templates, (template) => template.Title === title);
|
||||
if (isNotUnique) {
|
||||
this.state.formValidationError = 'A template with the same name already exists';
|
||||
return false;
|
||||
}
|
||||
|
||||
const isAdmin = this.Authentication.isAdmin();
|
||||
const accessControlData = this.formValues.AccessControlData;
|
||||
const error = this.FormValidator.validateAccessControl(accessControlData, isAdmin);
|
||||
|
||||
if (error) {
|
||||
this.state.formValidationError = error;
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
createCustomTemplateByMethod(method) {
|
||||
switch (method) {
|
||||
case 'editor':
|
||||
return this.createCustomTemplateFromFileContent();
|
||||
case 'upload':
|
||||
return this.createCustomTemplateFromFileUpload();
|
||||
case 'repository':
|
||||
return this.createCustomTemplateFromGitRepository();
|
||||
}
|
||||
}
|
||||
|
||||
createCustomTemplateFromFileContent() {
|
||||
return this.CustomTemplateService.createCustomTemplateFromFileContent(this.formValues);
|
||||
}
|
||||
|
||||
createCustomTemplateFromFileUpload() {
|
||||
return this.CustomTemplateService.createCustomTemplateFromFileUpload(this.formValues);
|
||||
}
|
||||
|
||||
createCustomTemplateFromGitRepository() {
|
||||
return this.CustomTemplateService.createCustomTemplateFromGitRepository(this.formValues);
|
||||
}
|
||||
|
||||
editorUpdate(value) {
|
||||
this.formValues.FileContent = value;
|
||||
this.state.isEditorDirty = true;
|
||||
this.parseTemplate(value);
|
||||
}
|
||||
|
||||
parseTemplate(templateStr) {
|
||||
if (!this.isTemplateVariablesEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [variables] = getTemplateVariables(templateStr);
|
||||
|
||||
const isValid = !!variables;
|
||||
|
||||
this.state.isTemplateValid = isValid;
|
||||
|
||||
if (isValid) {
|
||||
this.onVariablesChange(intersectVariables(this.formValues.Variables, variables));
|
||||
}
|
||||
}
|
||||
|
||||
async $onInit() {
|
||||
return this.$async(async () => {
|
||||
const applicationState = this.StateManager.getState();
|
||||
|
||||
this.state.endpointMode = applicationState.endpoint.mode;
|
||||
let stackType = 0;
|
||||
if (this.state.endpointMode.provider === 'DOCKER_STANDALONE') {
|
||||
this.isDockerStandalone = true;
|
||||
stackType = 2;
|
||||
} else if (this.state.endpointMode.provider === 'DOCKER_SWARM_MODE') {
|
||||
stackType = 1;
|
||||
}
|
||||
this.formValues.Type = stackType;
|
||||
|
||||
const { appTemplateId, type } = this.$state.params;
|
||||
|
||||
if (type) {
|
||||
this.formValues.Type = +type;
|
||||
}
|
||||
|
||||
if (appTemplateId) {
|
||||
this.formValues.FileContent = await fetchFilePreview(appTemplateId);
|
||||
}
|
||||
|
||||
try {
|
||||
this.templates = await this.CustomTemplateService.customTemplates([1, 2]);
|
||||
} catch (err) {
|
||||
this.Notifications.error('Failure loading', err, 'Failed loading custom templates');
|
||||
}
|
||||
|
||||
this.state.loading = false;
|
||||
|
||||
this.$window.onbeforeunload = () => {
|
||||
if (this.state.Method === 'editor' && this.formValues.FileContent && this.state.isEditorDirty) {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
$onDestroy() {
|
||||
this.state.isEditorDirty = false;
|
||||
}
|
||||
|
||||
async uiCanExit() {
|
||||
if (this.state.Method === 'editor' && this.formValues.FileContent && this.state.isEditorDirty) {
|
||||
return confirmWebEditorDiscard();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default CreateCustomTemplateViewController;
|
|
@ -1,6 +0,0 @@
|
|||
import CreateCustomTemplateViewController from './createCustomTemplateViewController.js';
|
||||
|
||||
angular.module('portainer.app').component('createCustomTemplateView', {
|
||||
templateUrl: './createCustomTemplateView.html',
|
||||
controller: CreateCustomTemplateViewController,
|
||||
});
|
|
@ -0,0 +1,51 @@
|
|||
import { StackType } from '../types';
|
||||
|
||||
const dockerTexts = {
|
||||
editor: {
|
||||
placeholder: 'Define or paste the content of your docker compose file here',
|
||||
description: (
|
||||
<p>
|
||||
You can get more information about Compose file format in the{' '}
|
||||
<a
|
||||
href="https://docs.docker.com/compose/compose-file/"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
official documentation
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
),
|
||||
},
|
||||
upload: 'You can upload a Compose file from your computer.',
|
||||
} as const;
|
||||
|
||||
export const textByType = {
|
||||
[StackType.DockerCompose]: dockerTexts,
|
||||
[StackType.DockerSwarm]: dockerTexts,
|
||||
[StackType.Kubernetes]: {
|
||||
editor: {
|
||||
placeholder: 'Define or paste the content of your manifest file here',
|
||||
description: (
|
||||
<>
|
||||
<p>
|
||||
Templates allow deploying any kind of Kubernetes resource
|
||||
(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>
|
||||
</>
|
||||
),
|
||||
},
|
||||
upload: 'You can upload a Manifest file from your computer.',
|
||||
},
|
||||
} as const;
|
|
@ -1,120 +0,0 @@
|
|||
import { Formik } from 'formik';
|
||||
import { useCurrentStateAndParams, useRouter } from '@uirouter/react';
|
||||
|
||||
import { StackType } from '@/react/common/stacks/types';
|
||||
import { notifySuccess } from '@/portainer/services/notifications';
|
||||
import { useCreateTemplateMutation } from '@/react/portainer/templates/custom-templates/queries/useCreateTemplateMutation';
|
||||
import { Platform } from '@/react/portainer/templates/types';
|
||||
import { useFetchTemplateFile } from '@/react/portainer/templates/app-templates/queries/useFetchTemplateFile';
|
||||
import { getDefaultEdgeTemplateSettings } from '@/react/portainer/templates/custom-templates/types';
|
||||
import { useSaveCredentialsIfRequired } from '@/react/portainer/account/git-credentials/queries/useCreateGitCredentialsMutation';
|
||||
|
||||
import { editor } from '@@/BoxSelector/common-options/build-methods';
|
||||
|
||||
import { toGitRequest } from '../common/git';
|
||||
|
||||
import { InnerForm } from './InnerForm';
|
||||
import { FormValues } from './types';
|
||||
import { useValidation } from './useValidation';
|
||||
|
||||
export function CreateTemplateForm() {
|
||||
const router = useRouter();
|
||||
const mutation = useCreateTemplateMutation();
|
||||
const validation = useValidation();
|
||||
const { appTemplateId, type } = useParams();
|
||||
const { saveCredentials, isLoading: isSaveCredentialsLoading } =
|
||||
useSaveCredentialsIfRequired();
|
||||
|
||||
const fileContentQuery = useFetchTemplateFile(appTemplateId);
|
||||
|
||||
if (fileContentQuery.isLoading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const initialValues: FormValues = {
|
||||
Title: '',
|
||||
FileContent: fileContentQuery.data ?? '',
|
||||
Type: type,
|
||||
File: undefined,
|
||||
Method: editor.value,
|
||||
Description: '',
|
||||
Note: '',
|
||||
Logo: '',
|
||||
Platform: Platform.LINUX,
|
||||
Variables: [],
|
||||
Git: {
|
||||
RepositoryURL: '',
|
||||
RepositoryReferenceName: '',
|
||||
RepositoryAuthentication: false,
|
||||
RepositoryUsername: '',
|
||||
RepositoryPassword: '',
|
||||
ComposeFilePathInRepository: 'docker-compose.yml',
|
||||
AdditionalFiles: [],
|
||||
RepositoryURLValid: true,
|
||||
TLSSkipVerify: false,
|
||||
},
|
||||
EdgeSettings: getDefaultEdgeTemplateSettings(),
|
||||
};
|
||||
|
||||
return (
|
||||
<Formik
|
||||
initialValues={initialValues}
|
||||
onSubmit={handleSubmit}
|
||||
validationSchema={validation}
|
||||
validateOnMount
|
||||
>
|
||||
<InnerForm isLoading={mutation.isLoading || isSaveCredentialsLoading} />
|
||||
</Formik>
|
||||
);
|
||||
|
||||
async function handleSubmit(values: FormValues) {
|
||||
const credentialId = await saveCredentials(values.Git);
|
||||
|
||||
mutation.mutate(
|
||||
{
|
||||
...values,
|
||||
EdgeTemplate: true,
|
||||
Git: toGitRequest(values.Git, credentialId),
|
||||
},
|
||||
{
|
||||
onSuccess() {
|
||||
notifySuccess('Success', 'Template created');
|
||||
router.stateService.go('^');
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function useParams() {
|
||||
const {
|
||||
params: { type = StackType.DockerCompose, appTemplateId },
|
||||
} = useCurrentStateAndParams();
|
||||
|
||||
return {
|
||||
type: getStackType(type),
|
||||
appTemplateId: getTemplateId(appTemplateId),
|
||||
};
|
||||
|
||||
function getStackType(type: string): StackType {
|
||||
const typeNum = parseInt(type, 10);
|
||||
|
||||
if (
|
||||
[
|
||||
StackType.DockerSwarm,
|
||||
StackType.DockerCompose,
|
||||
StackType.Kubernetes,
|
||||
].includes(typeNum)
|
||||
) {
|
||||
return typeNum;
|
||||
}
|
||||
|
||||
return StackType.DockerCompose;
|
||||
}
|
||||
|
||||
function getTemplateId(appTemplateId: string): number | undefined {
|
||||
const id = parseInt(appTemplateId, 10);
|
||||
|
||||
return Number.isNaN(id) ? undefined : id;
|
||||
}
|
||||
}
|
|
@ -1,28 +0,0 @@
|
|||
import { PageHeader } from '@@/PageHeader';
|
||||
import { Widget } from '@@/Widget';
|
||||
|
||||
import { CreateTemplateForm } from './CreateTemplateForm';
|
||||
|
||||
export function CreateView() {
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="Create Custom template"
|
||||
breadcrumbs={[
|
||||
{ label: 'Custom Templates', link: '^' },
|
||||
'Create Custom template',
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="row">
|
||||
<div className="col-sm-12">
|
||||
<Widget>
|
||||
<Widget.Body>
|
||||
<CreateTemplateForm />
|
||||
</Widget.Body>
|
||||
</Widget>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -13,14 +13,13 @@ import {
|
|||
import { TemplateTypeSelector } from '@/react/portainer/custom-templates/components/TemplateTypeSelector';
|
||||
import { applySetStateAction } from '@/react-tools/apply-set-state-action';
|
||||
import { EdgeTemplateSettings } from '@/react/portainer/templates/custom-templates/types';
|
||||
import { EdgeSettingsFieldset } from '@/react/portainer/templates/custom-templates/CreateView/EdgeSettingsFieldset';
|
||||
|
||||
import { WebEditorForm, usePreventExit } from '@@/WebEditorForm';
|
||||
import { FormActions } from '@@/form-components/FormActions';
|
||||
import { Button } from '@@/buttons';
|
||||
import { FormError } from '@@/form-components/FormError';
|
||||
|
||||
import { EdgeSettingsFieldset } from '../CreateView/EdgeSettingsFieldset';
|
||||
|
||||
import { FormValues } from './types';
|
||||
|
||||
export function InnerForm({
|
||||
|
|
|
@ -10,8 +10,7 @@ import { useGitCredentials } from '@/react/portainer/account/git-credentials/git
|
|||
import { useCurrentUser } from '@/react/hooks/useUser';
|
||||
import { useCustomTemplates } from '@/react/portainer/templates/custom-templates/queries/useCustomTemplates';
|
||||
import { Platform } from '@/react/portainer/templates/types';
|
||||
|
||||
import { edgeFieldsetValidation } from '../CreateView/EdgeSettingsFieldset.validation';
|
||||
import { edgeFieldsetValidation } from '@/react/portainer/templates/custom-templates/CreateView/EdgeSettingsFieldset.validation';
|
||||
|
||||
export function useValidation(
|
||||
currentTemplateId: CustomTemplate['Id'],
|
||||
|
|
|
@ -43,7 +43,7 @@ export function TeamsField({
|
|||
/>
|
||||
) : (
|
||||
<span className="small text-muted">
|
||||
You have not yet created any teams. Head over to the
|
||||
You have not yet created any teams. Head over to the{' '}
|
||||
<Link to="portainer.teams">Teams view</Link> to manage teams.
|
||||
</span>
|
||||
)}
|
||||
|
|
|
@ -34,7 +34,7 @@ export function UsersField({ name, users, value, onChange, errors }: Props) {
|
|||
/>
|
||||
) : (
|
||||
<span className="small text-muted">
|
||||
You have not yet created any users. Head over to the
|
||||
You have not yet created any users. Head over to the{' '}
|
||||
<Link to="portainer.users">Users view</Link> to manage users.
|
||||
</span>
|
||||
)}
|
||||
|
|
|
@ -2,8 +2,9 @@ import { useQueryClient, useMutation } from 'react-query';
|
|||
|
||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||
import { notifyError, notifySuccess } from '@/portainer/services/notifications';
|
||||
import { GitAuthModel } from '@/react/portainer/gitops/types';
|
||||
import { GitAuthModel, GitFormModel } from '@/react/portainer/gitops/types';
|
||||
import { useCurrentUser } from '@/react/hooks/useUser';
|
||||
import { UserId } from '@/portainer/users/types';
|
||||
|
||||
import { GitCredential } from '../types';
|
||||
import { buildGitUrl } from '../git-credentials.service';
|
||||
|
@ -80,3 +81,40 @@ export function useSaveCredentialsIfRequired() {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveGitCredentialsIfNeeded(
|
||||
userId: UserId,
|
||||
gitModel: GitFormModel
|
||||
) {
|
||||
let credentialsId = gitModel.RepositoryGitCredentialID;
|
||||
let username = gitModel.RepositoryUsername;
|
||||
let password = gitModel.RepositoryPassword;
|
||||
if (
|
||||
gitModel.SaveCredential &&
|
||||
gitModel.RepositoryAuthentication &&
|
||||
password &&
|
||||
username &&
|
||||
gitModel.NewCredentialName
|
||||
) {
|
||||
const cred = await createGitCredential({
|
||||
name: gitModel.NewCredentialName,
|
||||
password,
|
||||
username,
|
||||
userId,
|
||||
});
|
||||
credentialsId = cred.id;
|
||||
}
|
||||
|
||||
// clear username and password if credentials are provided
|
||||
if (credentialsId && username) {
|
||||
username = '';
|
||||
password = '';
|
||||
}
|
||||
|
||||
return {
|
||||
...gitModel,
|
||||
RepositoryGitCredentialID: credentialsId,
|
||||
RepositoryUsername: username,
|
||||
RepositoryPassword: password,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -1,26 +1,39 @@
|
|||
import { useQuery } from 'react-query';
|
||||
|
||||
import { getEndpoint } from '@/react/portainer/environments/environment.service';
|
||||
import {
|
||||
Environment,
|
||||
EnvironmentId,
|
||||
} from '@/react/portainer/environments/types';
|
||||
import { withError } from '@/react-tools/react-query';
|
||||
|
||||
import { getDeploymentOptions, getEndpoint } from '../environment.service';
|
||||
import { Environment, EnvironmentId } from '../types';
|
||||
|
||||
import { environmentQueryKeys } from './query-keys';
|
||||
|
||||
export function useEnvironment<T = Environment | null>(
|
||||
id?: EnvironmentId,
|
||||
select?: (environment: Environment | null) => T
|
||||
environmentId?: EnvironmentId,
|
||||
select?: (environment: Environment | null) => T,
|
||||
options?: { autoRefreshRate?: number }
|
||||
) {
|
||||
return useQuery(
|
||||
id ? environmentQueryKeys.item(id) : [],
|
||||
() => (id ? getEndpoint(id) : null),
|
||||
environmentId ? environmentQueryKeys.item(environmentId) : [],
|
||||
() => (environmentId ? getEndpoint(environmentId) : null),
|
||||
{
|
||||
select,
|
||||
...withError('Failed loading environment'),
|
||||
staleTime: 50,
|
||||
enabled: !!id,
|
||||
enabled: !!environmentId,
|
||||
refetchInterval() {
|
||||
return options?.autoRefreshRate ?? false;
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export function useEnvironmentDeploymentOptions(id: EnvironmentId | undefined) {
|
||||
return useQuery(
|
||||
[...environmentQueryKeys.item(id!), 'deploymentOptions'],
|
||||
() => getDeploymentOptions(id!),
|
||||
{
|
||||
enabled: !!id,
|
||||
...withError('Failed loading deployment options'),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
|
|
@ -24,12 +24,12 @@ export function NewCredentialForm({
|
|||
<Checkbox
|
||||
id="repository-save-credential"
|
||||
label="save credential"
|
||||
checked={value.SaveCredential}
|
||||
checked={value.SaveCredential || false}
|
||||
className="[&+label]:mb-0"
|
||||
onChange={(e) => onChange({ SaveCredential: e.target.checked })}
|
||||
/>
|
||||
<Input
|
||||
value={value.NewCredentialName}
|
||||
value={value.NewCredentialName || ''}
|
||||
name="new_credential_name"
|
||||
placeholder="credential name"
|
||||
className="ml-4 w-48"
|
||||
|
|
|
@ -0,0 +1,91 @@
|
|||
import { Formik } from 'formik';
|
||||
import { useRouter } from '@uirouter/react';
|
||||
|
||||
import { StackType } from '@/react/common/stacks/types';
|
||||
import { notifySuccess } from '@/portainer/services/notifications';
|
||||
import { useCreateTemplateMutation } from '@/react/portainer/templates/custom-templates/queries/useCreateTemplateMutation';
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
import { useEnvironmentDeploymentOptions } from '@/react/portainer/environments/queries/useEnvironment';
|
||||
import { useCurrentEnvironment } from '@/react/hooks/useCurrentEnvironment';
|
||||
import { isKubernetesEnvironment } from '@/react/portainer/environments/utils';
|
||||
|
||||
import { useInitialValues } from './useInitialValues';
|
||||
import { FormValues, initialBuildMethods } from './types';
|
||||
import { useValidation } from './useValidation';
|
||||
import { InnerForm } from './InnerForm';
|
||||
|
||||
export function CreateForm({
|
||||
environmentId,
|
||||
defaultType,
|
||||
}: {
|
||||
environmentId?: EnvironmentId;
|
||||
defaultType: StackType;
|
||||
}) {
|
||||
const isEdge = !environmentId;
|
||||
const router = useRouter();
|
||||
const mutation = useCreateTemplateMutation();
|
||||
const validation = useValidation(isEdge);
|
||||
const buildMethods = useBuildMethods();
|
||||
|
||||
const initialValues = useInitialValues({
|
||||
defaultType,
|
||||
isEdge,
|
||||
buildMethods: buildMethods.map((method) => method.value),
|
||||
});
|
||||
|
||||
if (!initialValues) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Formik
|
||||
initialValues={initialValues}
|
||||
onSubmit={handleSubmit}
|
||||
validationSchema={validation}
|
||||
validateOnMount
|
||||
>
|
||||
<InnerForm
|
||||
isLoading={mutation.isLoading}
|
||||
environmentId={environmentId}
|
||||
buildMethods={buildMethods}
|
||||
/>
|
||||
</Formik>
|
||||
);
|
||||
|
||||
function handleSubmit(values: FormValues) {
|
||||
mutation.mutate(
|
||||
{
|
||||
...values,
|
||||
EdgeTemplate: isEdge,
|
||||
},
|
||||
{
|
||||
onSuccess() {
|
||||
notifySuccess('Success', 'Template created');
|
||||
router.stateService.go('^');
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function useBuildMethods() {
|
||||
const environment = useCurrentEnvironment(false);
|
||||
|
||||
const deploymentOptionsQuery = useEnvironmentDeploymentOptions(
|
||||
environment.data && isKubernetesEnvironment(environment.data.Type)
|
||||
? environment.data.Id
|
||||
: undefined
|
||||
);
|
||||
return initialBuildMethods.filter((method) => {
|
||||
switch (method.value) {
|
||||
case 'editor':
|
||||
return !deploymentOptionsQuery.data?.hideWebEditor;
|
||||
case 'upload':
|
||||
return !deploymentOptionsQuery.data?.hideFileUpload;
|
||||
case 'repository':
|
||||
return true;
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
});
|
||||
}
|
|
@ -0,0 +1,51 @@
|
|||
import { useCurrentStateAndParams } from '@uirouter/react';
|
||||
|
||||
import { StackType } from '@/react/common/stacks/types';
|
||||
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
|
||||
|
||||
import { PageHeader } from '@@/PageHeader';
|
||||
import { Widget } from '@@/Widget';
|
||||
|
||||
import { CreateForm } from './CreateForm';
|
||||
|
||||
export function CreateView() {
|
||||
const defaultType = useDefaultType();
|
||||
const environmentId = useEnvironmentId(false);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="Create Custom template"
|
||||
breadcrumbs={[
|
||||
{ label: 'Custom Templates', link: '^' },
|
||||
'Create Custom template',
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="row">
|
||||
<div className="col-sm-12">
|
||||
<Widget>
|
||||
<Widget.Body>
|
||||
<CreateForm
|
||||
defaultType={defaultType}
|
||||
environmentId={environmentId}
|
||||
/>
|
||||
</Widget.Body>
|
||||
</Widget>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function useDefaultType() {
|
||||
const {
|
||||
state: { name },
|
||||
} = useCurrentStateAndParams();
|
||||
if (name?.includes('kubernetes')) {
|
||||
return StackType.Kubernetes;
|
||||
}
|
||||
|
||||
// edge or docker
|
||||
return StackType.DockerCompose;
|
||||
}
|
|
@ -10,24 +10,33 @@ import {
|
|||
isTemplateVariablesEnabled,
|
||||
} from '@/react/portainer/custom-templates/components/utils';
|
||||
import { TemplateTypeSelector } from '@/react/portainer/custom-templates/components/TemplateTypeSelector';
|
||||
import { EdgeTemplateSettings } from '@/react/portainer/templates/custom-templates/types';
|
||||
import { AccessControlForm } from '@/react/portainer/access-control';
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
import { applySetStateAction } from '@/react-tools/apply-set-state-action';
|
||||
import { AccessControlFormData } from '@/react/portainer/access-control/types';
|
||||
import { textByType } from '@/react/common/stacks/common/form-texts';
|
||||
import { StackType } from '@/react/common/stacks/types';
|
||||
|
||||
import { BoxSelector } from '@@/BoxSelector';
|
||||
import { WebEditorForm, usePreventExit } from '@@/WebEditorForm';
|
||||
import { FileUploadForm } from '@@/form-components/FileUpload';
|
||||
import { FormActions } from '@@/form-components/FormActions';
|
||||
import { FormSection } from '@@/form-components/FormSection';
|
||||
import {
|
||||
editor,
|
||||
upload,
|
||||
git,
|
||||
} from '@@/BoxSelector/common-options/build-methods';
|
||||
|
||||
import { FormValues, Method, buildMethods } from './types';
|
||||
import { EdgeTemplateSettings } from '../types';
|
||||
|
||||
import { EdgeSettingsFieldset } from './EdgeSettingsFieldset';
|
||||
import { FormValues, Method, initialBuildMethods } from './types';
|
||||
|
||||
export function InnerForm({ isLoading }: { isLoading: boolean }) {
|
||||
export function InnerForm({
|
||||
isLoading,
|
||||
environmentId,
|
||||
buildMethods,
|
||||
}: {
|
||||
isLoading: boolean;
|
||||
environmentId?: EnvironmentId;
|
||||
buildMethods: Array<(typeof initialBuildMethods)[number]>;
|
||||
}) {
|
||||
const {
|
||||
values,
|
||||
initialValues,
|
||||
|
@ -39,13 +48,17 @@ export function InnerForm({ isLoading }: { isLoading: boolean }) {
|
|||
isSubmitting,
|
||||
} = useFormikContext<FormValues>();
|
||||
|
||||
const isGit = values.Method === 'repository';
|
||||
const isEditor = values.Method === 'editor';
|
||||
|
||||
usePreventExit(
|
||||
initialValues.FileContent,
|
||||
values.FileContent,
|
||||
values.Method === editor.value && !isSubmitting && !isLoading
|
||||
isEditor && !isSubmitting && !isLoading
|
||||
);
|
||||
|
||||
const isGit = values.Method === git.value;
|
||||
const texts = textByType[values.Type];
|
||||
|
||||
return (
|
||||
<Form className="form-horizontal">
|
||||
<CommonFields
|
||||
|
@ -56,15 +69,19 @@ export function InnerForm({ isLoading }: { isLoading: boolean }) {
|
|||
errors={errors}
|
||||
/>
|
||||
|
||||
<PlatformField
|
||||
value={values.Platform}
|
||||
onChange={(value) => setFieldValue('Platform', value)}
|
||||
/>
|
||||
{values.Type !== StackType.Kubernetes && (
|
||||
<>
|
||||
<PlatformField
|
||||
value={values.Platform}
|
||||
onChange={(value) => setFieldValue('Platform', value)}
|
||||
/>
|
||||
|
||||
<TemplateTypeSelector
|
||||
value={values.Type}
|
||||
onChange={(value) => setFieldValue('Type', value)}
|
||||
/>
|
||||
<TemplateTypeSelector
|
||||
value={values.Type}
|
||||
onChange={(value) => setFieldValue('Type', value)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<FormSection title="Build method">
|
||||
<BoxSelector
|
||||
|
@ -76,32 +93,22 @@ export function InnerForm({ isLoading }: { isLoading: boolean }) {
|
|||
/>
|
||||
</FormSection>
|
||||
|
||||
{values.Method === editor.value && (
|
||||
{isEditor && (
|
||||
<WebEditorForm
|
||||
id="custom-template-creation-editor"
|
||||
value={values.FileContent}
|
||||
onChange={handleChangeFileContent}
|
||||
yaml
|
||||
placeholder="Define or paste the content of your docker compose file here"
|
||||
placeholder={texts.editor.placeholder}
|
||||
error={errors.FileContent}
|
||||
>
|
||||
<p>
|
||||
You can get more information about Compose file format in the{' '}
|
||||
<a
|
||||
href="https://docs.docker.com/compose/compose-file/"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
official documentation
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
{texts.editor.description}
|
||||
</WebEditorForm>
|
||||
)}
|
||||
|
||||
{values.Method === upload.value && (
|
||||
{values.Method === 'upload' && (
|
||||
<FileUploadForm
|
||||
description="You can upload a Compose file from your computer."
|
||||
description={texts.upload}
|
||||
value={values.File}
|
||||
onChange={(value) => setFieldValue('File', value)}
|
||||
required
|
||||
|
@ -110,6 +117,9 @@ export function InnerForm({ isLoading }: { isLoading: boolean }) {
|
|||
|
||||
{isGit && (
|
||||
<GitForm
|
||||
deployMethod={
|
||||
values.Type === StackType.Kubernetes ? 'manifest' : 'compose'
|
||||
}
|
||||
value={values.Git}
|
||||
onChange={(newValues) =>
|
||||
setValues((values) => ({
|
||||
|
@ -125,11 +135,21 @@ export function InnerForm({ isLoading }: { isLoading: boolean }) {
|
|||
<CustomTemplatesVariablesDefinitionField
|
||||
value={values.Variables}
|
||||
onChange={(values) => setFieldValue('Variables', values)}
|
||||
isVariablesNamesFromParent={values.Method === editor.value}
|
||||
isVariablesNamesFromParent={isEditor}
|
||||
errors={errors.Variables}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!!values.AccessControl && (
|
||||
<AccessControlForm
|
||||
environmentId={environmentId || 0}
|
||||
onChange={(values) => setFieldValue('AccessControl', values)}
|
||||
values={values.AccessControl}
|
||||
errors={errors.AccessControl as FormikErrors<AccessControlFormData>}
|
||||
formNamespace="accessControl"
|
||||
/>
|
||||
)}
|
||||
|
||||
{values.EdgeSettings && (
|
||||
<EdgeSettingsFieldset
|
||||
setValues={(edgeSetValues) =>
|
|
@ -3,7 +3,7 @@ import { type Values as CommonFieldsValues } from '@/react/portainer/custom-temp
|
|||
import { DefinitionFieldValues } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesDefinitionField';
|
||||
import { Platform } from '@/react/portainer/templates/types';
|
||||
import { GitFormModel } from '@/react/portainer/gitops/types';
|
||||
import { EdgeTemplateSettings } from '@/react/portainer/templates/custom-templates/types';
|
||||
import { AccessControlFormData } from '@/react/portainer/access-control/types';
|
||||
|
||||
import {
|
||||
editor,
|
||||
|
@ -11,9 +11,11 @@ import {
|
|||
git,
|
||||
} from '@@/BoxSelector/common-options/build-methods';
|
||||
|
||||
export const buildMethods = [editor, upload, git] as const;
|
||||
import { EdgeTemplateSettings } from '../types';
|
||||
|
||||
export type Method = (typeof buildMethods)[number]['value'];
|
||||
export const initialBuildMethods = [editor, upload, git] as const;
|
||||
|
||||
export type Method = (typeof initialBuildMethods)[number]['value'];
|
||||
|
||||
export interface FormValues extends CommonFieldsValues {
|
||||
Platform: Platform;
|
||||
|
@ -23,5 +25,6 @@ export interface FormValues extends CommonFieldsValues {
|
|||
File: File | undefined;
|
||||
Git: GitFormModel;
|
||||
Variables: DefinitionFieldValues;
|
||||
AccessControl?: AccessControlFormData;
|
||||
EdgeSettings?: EdgeTemplateSettings;
|
||||
}
|
|
@ -0,0 +1,94 @@
|
|||
import { useCurrentStateAndParams } from '@uirouter/react';
|
||||
|
||||
import { parseAccessControlFormData } from '@/react/portainer/access-control/utils';
|
||||
import { useCurrentUser } from '@/react/hooks/useUser';
|
||||
import { StackType } from '@/react/common/stacks/types';
|
||||
|
||||
import { Platform } from '../../types';
|
||||
import { useFetchTemplateFile } from '../../app-templates/queries/useFetchTemplateFile';
|
||||
import { getDefaultEdgeTemplateSettings } from '../types';
|
||||
|
||||
import { FormValues, Method } from './types';
|
||||
|
||||
export function useInitialValues({
|
||||
defaultType,
|
||||
isEdge = false,
|
||||
buildMethods,
|
||||
}: {
|
||||
defaultType: StackType;
|
||||
isEdge?: boolean;
|
||||
buildMethods: Array<Method>;
|
||||
}): FormValues | undefined {
|
||||
const { user, isAdmin } = useCurrentUser();
|
||||
const { appTemplateId, type = defaultType } = useAppTemplateParams();
|
||||
|
||||
const fileContentQuery = useFetchTemplateFile(appTemplateId);
|
||||
if (fileContentQuery.isLoading) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
Title: '',
|
||||
FileContent: fileContentQuery.data ?? '',
|
||||
Type: type,
|
||||
Platform: Platform.LINUX,
|
||||
File: undefined,
|
||||
Method: buildMethods[0],
|
||||
Description: '',
|
||||
Note: '',
|
||||
Logo: '',
|
||||
Variables: [],
|
||||
Git: {
|
||||
RepositoryURL: '',
|
||||
RepositoryReferenceName: '',
|
||||
RepositoryAuthentication: false,
|
||||
RepositoryUsername: '',
|
||||
RepositoryPassword: '',
|
||||
ComposeFilePathInRepository: 'docker-compose.yml',
|
||||
AdditionalFiles: [],
|
||||
RepositoryURLValid: true,
|
||||
TLSSkipVerify: false,
|
||||
},
|
||||
AccessControl: isEdge
|
||||
? undefined
|
||||
: parseAccessControlFormData(isAdmin, user.Id),
|
||||
EdgeSettings: isEdge ? getDefaultEdgeTemplateSettings() : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function useAppTemplateParams() {
|
||||
const {
|
||||
params: { type, appTemplateId },
|
||||
} = useCurrentStateAndParams();
|
||||
|
||||
return {
|
||||
type: getStackType(type),
|
||||
appTemplateId: getTemplateId(appTemplateId),
|
||||
};
|
||||
|
||||
function getStackType(type: string): StackType | undefined {
|
||||
if (!type) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const typeNum = parseInt(type, 10);
|
||||
|
||||
if (
|
||||
[
|
||||
StackType.DockerSwarm,
|
||||
StackType.DockerCompose,
|
||||
StackType.Kubernetes,
|
||||
].includes(typeNum)
|
||||
) {
|
||||
return typeNum;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function getTemplateId(appTemplateId: string): number | undefined {
|
||||
const id = parseInt(appTemplateId, 10);
|
||||
|
||||
return Number.isNaN(id) ? undefined : id;
|
||||
}
|
||||
}
|
|
@ -9,6 +9,7 @@ import { buildGitValidationSchema } from '@/react/portainer/gitops/GitForm';
|
|||
import { useGitCredentials } from '@/react/portainer/account/git-credentials/git-credentials.service';
|
||||
import { useCurrentUser } from '@/react/hooks/useUser';
|
||||
import { useCustomTemplates } from '@/react/portainer/templates/custom-templates/queries/useCustomTemplates';
|
||||
import { edgeFieldsetValidation } from '@/react/portainer/templates/custom-templates/CreateView/EdgeSettingsFieldset.validation';
|
||||
|
||||
import { file } from '@@/form-components/yup-file-validation';
|
||||
import {
|
||||
|
@ -17,10 +18,9 @@ import {
|
|||
upload,
|
||||
} from '@@/BoxSelector/common-options/build-methods';
|
||||
|
||||
import { buildMethods } from './types';
|
||||
import { edgeFieldsetValidation } from './EdgeSettingsFieldset.validation';
|
||||
import { initialBuildMethods } from './types';
|
||||
|
||||
export function useValidation() {
|
||||
export function useValidation(isEdge: boolean) {
|
||||
const { user } = useCurrentUser();
|
||||
const gitCredentialsQuery = useGitCredentials(user.Id);
|
||||
const customTemplatesQuery = useCustomTemplates();
|
||||
|
@ -38,7 +38,7 @@ export function useValidation() {
|
|||
StackType.Kubernetes,
|
||||
])
|
||||
.default(StackType.DockerCompose),
|
||||
Method: string().oneOf(buildMethods.map((m) => m.value)),
|
||||
Method: string().oneOf(initialBuildMethods.map((m) => m.value)),
|
||||
FileContent: string().when('Method', {
|
||||
is: editor.value,
|
||||
then: (schema) => schema.required('Template is required.'),
|
||||
|
@ -52,10 +52,10 @@ export function useValidation() {
|
|||
then: () => buildGitValidationSchema(gitCredentialsQuery.data || []),
|
||||
}),
|
||||
Variables: variablesValidation(),
|
||||
EdgeSettings: edgeFieldsetValidation(),
|
||||
EdgeSettings: isEdge ? edgeFieldsetValidation() : mixed(),
|
||||
}).concat(
|
||||
commonFieldsValidation({ templates: customTemplatesQuery.data })
|
||||
),
|
||||
[customTemplatesQuery.data, gitCredentialsQuery.data]
|
||||
[customTemplatesQuery.data, gitCredentialsQuery.data, isEdge]
|
||||
);
|
||||
}
|
|
@ -10,57 +10,96 @@ import {
|
|||
withInvalidate,
|
||||
} from '@/react-tools/react-query';
|
||||
import { StackType } from '@/react/common/stacks/types';
|
||||
import { FormValues } from '@/react/edge/templates/custom-templates/CreateView/types';
|
||||
import { VariableDefinition } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesDefinitionField/CustomTemplatesVariablesDefinitionField';
|
||||
import {
|
||||
CustomTemplate,
|
||||
EdgeTemplateSettings,
|
||||
} from '@/react/portainer/templates/custom-templates/types';
|
||||
import { GitFormModel } from '@/react/portainer/gitops/types';
|
||||
import { DefinitionFieldValues } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesDefinitionField';
|
||||
import { AccessControlFormData } from '@/react/portainer/access-control/types';
|
||||
import { applyResourceControl } from '@/react/portainer/access-control/access-control.service';
|
||||
import { useCurrentUser } from '@/react/hooks/useUser';
|
||||
import { UserId } from '@/portainer/users/types';
|
||||
import { saveGitCredentialsIfNeeded } from '@/react/portainer/account/git-credentials/queries/useCreateGitCredentialsMutation';
|
||||
|
||||
import { Platform } from '../../types';
|
||||
|
||||
import { buildUrl } from './build-url';
|
||||
import { queryKeys } from './query-keys';
|
||||
|
||||
interface CreateTemplatePayload {
|
||||
EdgeTemplate?: boolean;
|
||||
Platform: Platform;
|
||||
Type: StackType;
|
||||
Method: 'editor' | 'upload' | 'repository';
|
||||
FileContent: string;
|
||||
File: File | undefined;
|
||||
Git: GitFormModel;
|
||||
Variables: DefinitionFieldValues;
|
||||
EdgeSettings?: EdgeTemplateSettings;
|
||||
Title: string;
|
||||
Description: string;
|
||||
Note: string;
|
||||
Logo: string;
|
||||
AccessControl?: AccessControlFormData;
|
||||
}
|
||||
|
||||
export function useCreateTemplateMutation() {
|
||||
const { user } = useCurrentUser();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation(
|
||||
createTemplate,
|
||||
async (payload: CreateTemplatePayload) => {
|
||||
const template = await createTemplate(user.Id, payload);
|
||||
const resourceControl = template.ResourceControl;
|
||||
|
||||
if (resourceControl && payload.AccessControl) {
|
||||
await applyResourceControl(payload.AccessControl, resourceControl.Id);
|
||||
}
|
||||
|
||||
return template;
|
||||
},
|
||||
mutationOptions(
|
||||
withInvalidate(queryClient, [['custom-templates']]),
|
||||
withInvalidate(queryClient, [queryKeys.base()]),
|
||||
withGlobalError('Failed to create template')
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function createTemplate({
|
||||
Method,
|
||||
Git,
|
||||
...values
|
||||
}: FormValues & { EdgeTemplate?: boolean }) {
|
||||
switch (Method) {
|
||||
function createTemplate(userId: UserId, payload: CreateTemplatePayload) {
|
||||
switch (payload.Method) {
|
||||
case 'editor':
|
||||
return createTemplateFromText(values);
|
||||
return createTemplateFromText(payload);
|
||||
case 'upload':
|
||||
return createTemplateFromFile(values);
|
||||
return createTemplateFromFile(payload);
|
||||
case 'repository':
|
||||
return createTemplateFromGit({
|
||||
...values,
|
||||
...Git,
|
||||
...(values.EdgeSettings
|
||||
? {
|
||||
EdgeSettings: {
|
||||
...values.EdgeSettings,
|
||||
...values.EdgeSettings.RelativePathSettings,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
return createTemplateAndGitCredential(userId, payload);
|
||||
default:
|
||||
throw new Error('Unknown method');
|
||||
}
|
||||
}
|
||||
|
||||
async function createTemplateAndGitCredential(
|
||||
userId: UserId,
|
||||
{ Git: gitModel, ...values }: CreateTemplatePayload
|
||||
) {
|
||||
const newGitModel = await saveGitCredentialsIfNeeded(userId, gitModel);
|
||||
|
||||
return createTemplateFromGit({
|
||||
...values,
|
||||
...newGitModel,
|
||||
...(values.EdgeSettings
|
||||
? {
|
||||
EdgeSettings: {
|
||||
...values.EdgeSettings,
|
||||
...values.EdgeSettings.RelativePathSettings,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Payload for creating a custom template from file content.
|
||||
*/
|
||||
|
@ -179,6 +218,10 @@ interface CustomTemplateFromGitRepositoryPayload {
|
|||
RepositoryUsername?: string;
|
||||
/** Password used in basic authentication when RepositoryAuthentication is true. */
|
||||
RepositoryPassword?: string;
|
||||
/** GitCredentialID used to identify the bound git credential. Required when RepositoryAuthentication
|
||||
* is true and RepositoryUsername/RepositoryPassword are not provided
|
||||
*/
|
||||
RepositoryGitCredentialID?: number;
|
||||
/** Path to the Stack file inside the Git repository. */
|
||||
ComposeFilePathInRepository: string;
|
||||
/** Definitions of variables in the stack file. */
|
||||
|
|
Loading…
Reference in New Issue